From 38991b0163a30f59cb28b76518c9fe104c434ec9 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:37:56 -0600 Subject: [PATCH 01/64] feat: initial Rust Core (cpex-core and cpex-sdk) (#13) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor --- Cargo.lock | 812 ++++++++++++++++ Cargo.toml | 31 + crates/README.md | 209 +++++ crates/cpex-core/Cargo.toml | 27 + crates/cpex-core/src/config.rs | 15 + crates/cpex-core/src/context.rs | 119 +++ crates/cpex-core/src/error.rs | 127 +++ crates/cpex-core/src/executor.rs | 752 +++++++++++++++ crates/cpex-core/src/hooks/adapter.rs | 108 +++ crates/cpex-core/src/hooks/macros.rs | 70 ++ crates/cpex-core/src/hooks/mod.rs | 29 + crates/cpex-core/src/hooks/payload.rs | 174 ++++ crates/cpex-core/src/hooks/trait_def.rs | 272 ++++++ crates/cpex-core/src/hooks/types.rs | 190 ++++ crates/cpex-core/src/lib.rs | 30 + crates/cpex-core/src/manager.rs | 1121 +++++++++++++++++++++++ crates/cpex-core/src/plugin.rs | 414 +++++++++ crates/cpex-core/src/registry.rs | 625 +++++++++++++ crates/cpex-sdk/Cargo.toml | 22 + crates/cpex-sdk/src/lib.rs | 28 + 20 files changed, 5175 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/README.md create mode 100644 crates/cpex-core/Cargo.toml create mode 100644 crates/cpex-core/src/config.rs create mode 100644 crates/cpex-core/src/context.rs create mode 100644 crates/cpex-core/src/error.rs create mode 100644 crates/cpex-core/src/executor.rs create mode 100644 crates/cpex-core/src/hooks/adapter.rs create mode 100644 crates/cpex-core/src/hooks/macros.rs create mode 100644 crates/cpex-core/src/hooks/mod.rs create mode 100644 crates/cpex-core/src/hooks/payload.rs create mode 100644 crates/cpex-core/src/hooks/trait_def.rs create mode 100644 crates/cpex-core/src/hooks/types.rs create mode 100644 crates/cpex-core/src/lib.rs create mode 100644 crates/cpex-core/src/manager.rs create mode 100644 crates/cpex-core/src/plugin.rs create mode 100644 crates/cpex-core/src/registry.rs create mode 100644 crates/cpex-sdk/Cargo.toml create mode 100644 crates/cpex-sdk/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..b06faa5a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,812 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpex-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..03fcb104 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +# Location: ./Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Workspace root for the CPEX Rust crates. + +[workspace] +resolver = "2" +members = [ + "crates/cpex-core", + "crates/cpex-sdk", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["Teryl Taylor"] + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1" +async-trait = "0.1" +thiserror = "2" +tracing = "0.1" +uuid = { version = "1", features = ["v4"] } +paste = "1" +futures = "0.3" diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 00000000..62ace2ba --- /dev/null +++ b/crates/README.md @@ -0,0 +1,209 @@ +# CPEX Rust Core + +Phase 1a of the CPEX Rust plugin runtime. Provides the core types, 5-phase executor, and plugin manager for the ContextForge Plugin Extensibility Framework. + +## Status + +Phase 1a — core runtime functional, no language bindings yet. + +- `cpex-core`: Plugin trait, typed hooks, 5-phase executor, plugin manager +- `cpex-sdk`: Lean re-exports for plugin authors + +## 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.75+ (we develop on 1.94) +cargo --version +``` + +### Update an existing installation + +```bash +rustup update stable +``` + +### 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 + +```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. diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml new file mode 100644 index 00000000..4e0d4006 --- /dev/null +++ b/crates/cpex-core/Cargo.toml @@ -0,0 +1,27 @@ +# 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 + +[dependencies] +tokio = { 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 } +futures = { workspace = true } diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs new file mode 100644 index 00000000..02496747 --- /dev/null +++ b/crates/cpex-core/src/config.rs @@ -0,0 +1,15 @@ +// Location: ./crates/cpex-core/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Unified YAML configuration parsing. +// +// Parses the unified config format that combines global settings, +// plugin declarations, named policy groups, and per-entity routes +// into a single YAML document. +// +// Mirrors the unified config proposal in +// apl-plugins/docs/unified-config-proposal.md. + +// TODO: Implement CpexConfig, GlobalConfig, RouteEntry serde models diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs new file mode 100644 index 00000000..59e61a8a --- /dev/null +++ b/crates/cpex-core/src/context.rs @@ -0,0 +1,119 @@ +// 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 in plugin-framework-spec-v2.md §8.1. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Plugin Context +// --------------------------------------------------------------------------- + +/// 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() + } +} + +// --------------------------------------------------------------------------- +// Plugin Context Table +// --------------------------------------------------------------------------- + +/// Lookup table of `PluginContext` instances indexed by plugin ID. +/// +/// Threaded across hook invocations so that a plugin's `local_state` +/// persists from one hook to the next within the same request lifecycle +/// (e.g., `pre_invoke` → `post_invoke`). +/// +/// The caller receives the table back in `PipelineResult` and passes +/// it into the next hook invocation. On the first hook call, pass +/// `None` — the executor creates fresh contexts for each plugin. +pub type PluginContextTable = HashMap; diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs new file mode 100644 index 00000000..4b684d54 --- /dev/null +++ b/crates/cpex-core/src/error.rs @@ -0,0 +1,127 @@ +// 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; + +// --------------------------------------------------------------------------- +// Plugin Errors +// --------------------------------------------------------------------------- + +/// 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. +#[derive(Debug, Error)] +pub enum PluginError { + /// A plugin raised an execution error. + #[error("plugin '{plugin_name}' failed: {message}")] + Execution { + plugin_name: String, + message: String, + #[source] + source: Option>, + }, + + /// A plugin exceeded its execution timeout. + #[error("plugin '{plugin_name}' timed out after {timeout_ms}ms")] + Timeout { + plugin_name: String, + timeout_ms: u64, + }, + + /// 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 }, +} + +// --------------------------------------------------------------------------- +// Plugin Violations +// --------------------------------------------------------------------------- + +/// 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, +} + +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, + } + } + + /// 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 + } +} + +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 new file mode 100644 index 00000000..4b1188ef --- /dev/null +++ b/crates/cpex-core/src/executor.rs @@ -0,0 +1,752 @@ +// 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::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::timeout; +use tracing::{error, warn}; + +use crate::context::{PluginContext, PluginContextTable}; +use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::plugin::OnError; +use crate::registry::{group_by_mode, HookEntry}; + +// --------------------------------------------------------------------------- +// Executor Configuration +// --------------------------------------------------------------------------- + +/// 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, + } + } +} + +// --------------------------------------------------------------------------- +// Pipeline Result +// --------------------------------------------------------------------------- + +/// Aggregate result from a full hook invocation across all phases. +/// +/// Wraps the final payload, extensions, any violation, and the +/// context table. The caller should pass `context_table` into the +/// next hook invocation to preserve per-plugin local state across +/// hooks in the same request lifecycle. +#[derive(Debug)] +pub struct PipelineResult { + /// Whether the pipeline completed without a deny. + pub allowed: bool, + + /// The final payload after all modifications (type-erased). + /// `None` if the pipeline was denied before any modifications. + pub payload: Option>, + + /// The final extensions after all modifications. + pub extensions: Extensions, + + /// The violation that caused a deny, if any. + pub violation: 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 { + allowed: true, + payload: Some(payload), + extensions, + violation: None, + context_table, + } + } + + /// Pipeline was denied by a plugin. + pub fn denied( + violation: crate::error::PluginViolation, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { + Self { + allowed: false, + payload: None, + extensions, + violation: Some(violation), + context_table, + } + } +} + +// --------------------------------------------------------------------------- +// Executor +// --------------------------------------------------------------------------- + +/// 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. +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 `PipelineResult` with the final payload, extensions, violation, + /// and the updated context table for threading into the next hook. + pub async fn execute( + &self, + entries: &[HookEntry], + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> PipelineResult { + let mut ctx_table = context_table.unwrap_or_default(); + + if entries.is_empty() { + return PipelineResult::allowed_with(payload, extensions, ctx_table); + } + + // 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; + + // Phase 1: SEQUENTIAL — serial, chained, can block + modify + 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", + ) + .await + { + return PipelineResult::denied(v, current_extensions, ctx_table); + } + + // 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", + ) + .await; + + // Phase 3: AUDIT — serial, read-only, discard results + self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, &ctx_table, "AUDIT") + .await; + + // Phase 4: CONCURRENT — parallel, can block, cannot modify + if let Some(violation) = self + .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) + .await + { + return PipelineResult::denied(violation, current_extensions, ctx_table); + } + + // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results + self.spawn_fire_and_forget( + &fire_and_forget, + &*current_payload, + &ctx_table, + ); + + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + } + + // ----------------------------------------------------------------------- + // Phase 1 & 2: Serial execution (SEQUENTIAL / TRANSFORM) + // ----------------------------------------------------------------------- + + /// 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. + /// + /// 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. + 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, + ) -> Option { + // Extract current global state from the table (use last plugin's + // global_state, or start empty). We maintain a running copy that + // gets set on each plugin's context and merged back after. + let mut global_state = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let plugin_id = entry.plugin_ref.id().to_string(); + let on_error = entry.plugin_ref.trusted_config().on_error; + + // Look up existing context (preserves local_state from prior hooks) + // or create a fresh one. Set global_state to the current running copy. + let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); + ctx.global_state = global_state.clone(); + + // TODO: Capability-filter extensions per plugin (Phase 3) + let filtered = FilteredExtensions::default(); + + // Execute with timeout — handler borrows the payload + 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) { + // Check deny + if !erased.continue_processing && can_block { + if let Some(mut v) = erased.violation { + v.plugin_name = Some(plugin_name.clone()); + return Some(v); + } + } + + // Accept modifications + if can_modify { + if let Some(mp) = erased.modified_payload { + *payload = mp; + } + if let Some(me) = erased.modified_extensions { + // TODO: Merge with tier validation (Phase 3) + *extensions = me; + } + } + + // Merge global state changes back from the handler. + // The handler received &mut PluginContext and may have + // written to ctx.global_state directly. + if ctx.global_state != global_state { + global_state = ctx.global_state.clone(); + } + } + // If extract failed or no modifications — payload unchanged + } + Ok(Err(e)) => { + error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name); + return Some(v); + } + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); + } + } + } + Err(_) => { + error!("{} plugin '{}' timed out", phase_label, plugin_name); + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name); + return Some(v); + } + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); + } + } + } + } + + // Store context back into the table (preserves local_state + // for the next hook invocation via the returned context_table). + // Note: global_state merging from plugin writes is deferred — + // handlers currently receive &PluginContext (shared ref) so + // they can't mutate global_state directly. When we add write-back + // (via PluginResult or interior mutability), merge here. + ctx_table.insert(plugin_id, ctx); + } + + None // no denial + } + + // ----------------------------------------------------------------------- + // Phase 3 & 5: Read-only execution (AUDIT / FIRE_AND_FORGET) + // ----------------------------------------------------------------------- + + /// 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, + ) { + // Read-only phases get a snapshot of global state but don't merge back. + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let plugin_id = entry.plugin_ref.id(); + let mut ctx = ctx_table + .get(plugin_id) + .cloned() + .map(|mut c| { c.global_state = global_state.clone(); c }) + .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let filtered = FilteredExtensions::default(); + 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(_)) => {} // read-only — discard result + Ok(Err(e)) => { + warn!("{} plugin '{}' error (ignored): {}", phase_label, plugin_name, e); + } + Err(_) => { + warn!("{} plugin '{}' timed out (ignored)", phase_label, plugin_name); + } + } + } + } + + // ----------------------------------------------------------------------- + // Phase 4: Concurrent (parallel, fail-fast) + // ----------------------------------------------------------------------- + + /// Run the concurrent phase — plugins execute truly in parallel. + /// Returns the first violation if any plugin denies. + async fn run_concurrent_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx_table: &PluginContextTable, + ) -> Option { + if entries.is_empty() { + return None; + } + + // 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 global state for all concurrent plugins + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + // Spawn all handlers concurrently — each task returns just + // the invoke result. We zip outcomes back with entries to + // access PluginRef for disable() without cloning it into the spawn. + let mut handles = Vec::with_capacity(entries.len()); + + for entry in entries { + let handler = Arc::clone(&entry.handler); + let payload_clone = Arc::clone(&shared_payload); + let plugin_id = entry.plugin_ref.id().to_string(); + let mut ctx = ctx_table + .get(&plugin_id) + .cloned() + .map(|mut c| { c.global_state = global_state.clone(); c }) + .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let dur = timeout_dur; + + let handle = tokio::spawn(async move { + let filtered = FilteredExtensions::default(); + timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await + }); + + handles.push(handle); + } + + // Collect results — zip with entries for PluginRef access + let outcomes = futures::future::join_all(handles).await; + let mut denials = Vec::new(); + + for (entry, outcome) in entries.iter().zip(outcomes) { + let plugin_name = entry.plugin_ref.name(); + let on_error = entry.plugin_ref.trusted_config().on_error; + + let result = match outcome { + Ok(r) => r, + Err(e) => { + error!("CONCURRENT task panicked: {}", e); + continue; + } + }; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + if !erased.continue_processing { + let mut violation = erased.violation.unwrap_or_else(|| { + crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ) + }); + violation.plugin_name = Some(plugin_name.to_string()); + if self.config.short_circuit_on_deny { + return Some(violation); + } + denials.push(violation); + } + } + } + Ok(Err(e)) => match on_error { + OnError::Fail => { + 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); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); + entry.plugin_ref.disable(); + } + }, + Err(_) => match on_error { + OnError::Fail => { + 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::Ignore => { + warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); + entry.plugin_ref.disable(); + } + }, + } + } + + // Return first denial if any were collected (non-short-circuit mode) + denials.into_iter().next() + } + + // ----------------------------------------------------------------------- + // Phase 5: Fire-and-Forget (background, no await) + // ----------------------------------------------------------------------- + + /// 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. + fn spawn_fire_and_forget( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + ctx_table: &PluginContextTable, + ) { + if entries.is_empty() { + return; + } + + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + 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(); + let mut ctx = PluginContext::with_global_state(global_state.clone()); + let dur = timeout_dur; + + tokio::spawn(async move { + let filtered = FilteredExtensions::default(); + 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): {}", plugin_name, e); + } + Err(_) => { + warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", plugin_name); + } + } + }); + } + } +} + +impl Default for Executor { + fn default() -> Self { + Self::new(ExecutorConfig::default()) + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +// SerialResult removed — run_serial_phase now returns Option directly. + +// --------------------------------------------------------------------------- +// Erased Result Extraction +// --------------------------------------------------------------------------- + +/// 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)] + 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 ext = Extensions::default(); + ext.labels.insert("PII".into()); + let result: PluginResult = PluginResult::modify_extensions(ext); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_extensions.is_some()); + assert!(fields.modified_extensions.as_ref().unwrap().labels.contains("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.allowed); + assert!(result.payload.is_some()); + assert!(result.violation.is_none()); + } + + #[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.allowed); + assert!(result.payload.is_none()); + assert!(result.violation.is_some()); + } + + #[tokio::test] + async fn test_executor_empty_entries() { + let executor = Executor::default(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let result = executor + .execute(&[], payload, Extensions::default(), None) + .await; + assert!(result.allowed); + assert!(result.payload.is_some()); + } +} diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs new file mode 100644 index 00000000..e0339b95 --- /dev/null +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -0,0 +1,108 @@ +// Location: ./crates/cpex-core/src/hooks/adapter.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// TypedHandlerAdapter — bridges typed HookHandler to 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). + +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::{FilteredExtensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::plugin::Plugin; +use crate::registry::AnyHookHandler; + +// --------------------------------------------------------------------------- +// Typed Handler Adapter +// --------------------------------------------------------------------------- + +/// 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. +/// +/// # 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. + /// + /// 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. + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + 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); + 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 new file mode 100644 index 00000000..80012acf --- /dev/null +++ b/crates/cpex-core/src/hooks/macros.rs @@ -0,0 +1,70 @@ +// 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/mod.rs b/crates/cpex-core/src/hooks/mod.rs new file mode 100644 index 00000000..7f4d6ce4 --- /dev/null +++ b/crates/cpex-core/src/hooks/mod.rs @@ -0,0 +1,29 @@ +// 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. +// - [`FilteredExtensions`] — 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 payload; +pub mod trait_def; +pub mod types; + +// Re-export core types at the hooks level +pub use adapter::TypedHandlerAdapter; +pub use payload::{Extensions, FilteredExtensions, 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 new file mode 100644 index 00000000..c25f0247 --- /dev/null +++ b/crates/cpex-core/src/hooks/payload.rs @@ -0,0 +1,174 @@ +// 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::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Extensions (stub — fleshed out in Phase 3 with full CMF types) +// --------------------------------------------------------------------------- + +/// Typed container for all message extensions. +/// +/// Each field corresponds to an extension with an explicit mutability +/// tier enforced by the processing pipeline. Extensions are always +/// passed separately from the payload to handlers. +/// +/// This is a Phase 1 stub with minimal fields. Phase 3 adds the +/// full CMF extension types (SecurityExtension with MonotonicSet, +/// DelegationExtension with scope-narrowing chain, HttpExtension +/// with Guarded, MetaExtension, etc.). +/// +/// Mirrors Python's `cpex.framework.extensions.Extensions`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Extensions { + /// Security labels (monotonic — add-only in the full implementation). + #[serde(default)] + pub labels: std::collections::HashSet, + + /// Custom extensions (mutable — no restrictions). + #[serde(default)] + pub custom: HashMap, +} + +/// Capability-filtered view of Extensions for a specific plugin. +/// +/// Built by the framework before dispatching to each plugin. Fields +/// the plugin hasn't declared capabilities for are `None`. Plugins +/// receive this as a separate parameter — never inside the payload. +/// +/// Phase 1 stub — Phase 3 adds per-field capability gating matching +/// the Python `filter_extensions()` implementation. +#[derive(Debug, Clone, Default)] +pub struct FilteredExtensions { + /// Security labels (visible with `read_labels` capability). + pub labels: Option>, + + /// Custom extensions (always visible). + pub custom: Option>, +} + +// --------------------------------------------------------------------------- +// PluginPayload Trait +// --------------------------------------------------------------------------- + +/// 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 `&FilteredExtensions` 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") + } +} + +// --------------------------------------------------------------------------- +// Blanket helper macro for implementing 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 new file mode 100644 index 00000000..a437c955 --- /dev/null +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -0,0 +1,272 @@ +// 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, FilteredExtensions, PluginPayload}; +use crate::plugin::Plugin; + +// --------------------------------------------------------------------------- +// HookTypeDef Trait +// --------------------------------------------------------------------------- + +/// 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; +} + +// --------------------------------------------------------------------------- +// Hook Handler Trait +// --------------------------------------------------------------------------- + +/// 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. +/// +/// # Examples +/// +/// ```rust,ignore +/// impl HookHandler for MyPlugin { +/// fn handle( +/// &self, +/// payload: MessagePayload, +/// extensions: &FilteredExtensions, +/// ctx: &PluginContext, +/// ) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// +/// // Registration — no AnyHookHandler needed: +/// manager.register_handler::(plugin, config)?; +/// ``` +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. + fn handle( + &self, + payload: &H::Payload, + extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> H::Result; +} + +// --------------------------------------------------------------------------- +// Plugin Result +// --------------------------------------------------------------------------- + +/// 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, Clone)] +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. + /// Merged back by the framework using tier validation + /// (immutable rejected, monotonic superset-checked, etc.). + /// Only accepted from Sequential and Transform mode plugins. + 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. + pub fn modify_extensions(extensions: Extensions) -> Self { + Self { + continue_processing: true, + modified_payload: None, + modified_extensions: Some(extensions), + + violation: None, + metadata: None, + } + } + + /// Modify both payload and extensions. + pub fn modify(payload: P, extensions: Extensions) -> 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 new file mode 100644 index 00000000..3295d1a6 --- /dev/null +++ b/crates/cpex-core/src/hooks/types.rs @@ -0,0 +1,190 @@ +// 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}; + +// --------------------------------------------------------------------------- +// Hook Type +// --------------------------------------------------------------------------- + +/// 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) + } +} + +// --------------------------------------------------------------------------- +// Built-in Hook String Constants +// --------------------------------------------------------------------------- +// Canonical string names for built-in hooks. Use these with +// HookType::new() or pass them directly to APIs that accept &str. + +/// 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"; +} + +// --------------------------------------------------------------------------- +// Built-in hook type helpers +// --------------------------------------------------------------------------- + +/// 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/lib.rs b/crates/cpex-core/src/lib.rs new file mode 100644 index 00000000..2743b238 --- /dev/null +++ b/crates/cpex-core/src/lib.rs @@ -0,0 +1,30 @@ +// 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 +// - [`context`] — PluginContext (local_state + global_state) +// - [`error`] — Error types, violations, and result types + +pub mod config; +pub mod context; +pub mod error; +pub mod executor; +pub mod hooks; +pub mod manager; +pub mod plugin; +pub mod registry; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs new file mode 100644 index 00000000..a2a6e8a3 --- /dev/null +++ b/crates/cpex-core/src/manager.rs @@ -0,0 +1,1121 @@ +// Location: ./crates/cpex-core/src/manager.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 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::sync::Arc; + +use tracing::{error, info}; + +use crate::context::PluginContextTable; +use crate::error::PluginError; +use crate::executor::{Executor, ExecutorConfig, PipelineResult}; +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}; + +// --------------------------------------------------------------------------- +// Manager Configuration +// --------------------------------------------------------------------------- + +/// Configuration for the PluginManager. +#[derive(Debug, Clone)] +pub struct ManagerConfig { + /// Executor configuration (timeout, short-circuit behavior). + pub executor: ExecutorConfig, +} + +impl Default for ManagerConfig { + fn default() -> Self { + Self { + executor: ExecutorConfig::default(), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin Manager +// --------------------------------------------------------------------------- + +/// 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. +pub struct PluginManager { + /// Plugin registry — stores PluginRefs and hook-to-handler mappings. + registry: PluginRegistry, + + /// Executor — stateless 5-phase pipeline engine. + executor: Executor, + + /// Whether initialize() has been called. + initialized: bool, +} + +impl PluginManager { + /// Create a new PluginManager with the given configuration. + pub fn new(config: ManagerConfig) -> Self { + Self { + registry: PluginRegistry::new(), + executor: Executor::new(config.executor), + initialized: false, + } + } + + // ----------------------------------------------------------------------- + // Registration + // ----------------------------------------------------------------------- + + /// 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( + &mut self, + plugin: Arc

, + config: PluginConfig, + ) -> Result<(), PluginError> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, + { + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + self.registry + .register::(plugin, config, handler) + .map_err(|msg| PluginError::Config { message: msg }) + } + + /// 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( + &mut self, + plugin: Arc

, + config: PluginConfig, + names: &[&str], + ) -> Result<(), PluginError> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, + { + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + self.registry + .register_for_names::(plugin, config, handler, names) + .map_err(|msg| PluginError::Config { message: msg }) + } + + /// 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( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), PluginError> { + self.registry + .register::(plugin, config, handler) + .map_err(|msg| PluginError::Config { message: msg }) + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + /// 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(&mut self) -> Result<(), PluginError> { + if self.initialized { + return Ok(()); + } + + info!( + "Initializing PluginManager with {} plugins", + self.registry.plugin_count() + ); + + let mut initialized_plugins: Vec = Vec::new(); + + for name in self.registry.plugin_names() { + if let Some(plugin_ref) = self.registry.get(name) { + let plugin = plugin_ref.plugin().clone(); + let plugin_name = name.to_string(); + + if let Err(e) = plugin.initialize().await { + error!("Failed to initialize plugin '{}': {}", plugin_name, e); + + // Clean up already-initialized plugins + for init_name in initialized_plugins.iter().rev() { + if let Some(pr) = self.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(PluginError::Execution { + plugin_name, + message: format!("initialization failed: {}", e), + source: Some(Box::new(e)), + }); + } + + initialized_plugins.push(plugin_name); + } + } + + self.initialized = true; + 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. + pub async fn shutdown(&mut self) { + if !self.initialized { + return; + } + + info!("Shutting down PluginManager"); + + for name in self.registry.plugin_names() { + if let Some(plugin_ref) = self.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 = false; + info!("PluginManager shutdown complete"); + } + + // ----------------------------------------------------------------------- + // Hook Invocation — Dynamic (invoke_by_name) + // ----------------------------------------------------------------------- + + /// 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 `PipelineResult` with the final payload, extensions, violation, + /// and the updated context table. + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> PipelineResult { + let hook_type = HookType::new(hook_name); + let entries = self.registry.entries_for_hook(&hook_type); + + if entries.is_empty() { + return PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ); + } + + self.executor + .execute(entries, payload, extensions, context_table) + .await + } + + // ----------------------------------------------------------------------- + // Hook Invocation — Typed (invoke::) + // ----------------------------------------------------------------------- + + /// 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()`. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// * `payload` — the typed payload. + /// * `extensions` — the full extensions. + /// * `context_table` — optional context table from a previous hook. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload (type-erased — + /// caller downcasts via `as_any()`), extensions, violation, and + /// the updated context table. + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> PipelineResult { + let hook_type = HookType::new(H::NAME); + let entries = self.registry.entries_for_hook(&hook_type); + + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ); + } + + let boxed: Box = Box::new(payload); + self.executor + .execute(entries, boxed, extensions, context_table) + .await + } + + // ----------------------------------------------------------------------- + // Query Methods + // ----------------------------------------------------------------------- + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_name: &str) -> bool { + self.registry.has_hooks_for(&HookType::new(hook_name)) + } + + /// Look up a plugin by name. + pub fn get_plugin(&self, name: &str) -> Option<&PluginRef> { + self.registry.get(name) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.registry.plugin_count() + } + + /// All registered plugin names. + pub fn plugin_names(&self) -> Vec<&str> { + self.registry.plugin_names() + } + + /// Whether the manager has been initialized. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Unregister a plugin by name. + pub fn unregister(&mut self, name: &str) -> Option { + self.registry.unregister(name) + } +} + +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::FilteredExtensions; + 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<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for AllowPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &FilteredExtensions, + _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<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for DenyPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &FilteredExtensions, + _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: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + Err(PluginError::Execution { + plugin_name: "error-plugin".into(), + message: "simulated failure".into(), + source: 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, + } + } + + // -- Tests -- + + #[tokio::test] + async fn test_manager_lifecycle() { + let mut 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.allowed); + assert!(result.payload.is_some()); + } + + #[tokio::test] + async fn test_invoke_by_name_allow() { + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + // Clean registration — no AnyHookHandler needed + 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.allowed); + } + + #[tokio::test] + async fn test_invoke_by_name_deny() { + let mut 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.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_invoke_typed() { + let mut 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.allowed); + } + + #[tokio::test] + async fn test_has_hooks_for() { + let mut 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")); + } + + #[tokio::test] + async fn test_unregister() { + let mut 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")); + } + + #[tokio::test] + async fn test_audit_plugin_cannot_block() { + let mut 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.allowed); + } + + #[tokio::test] + async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { + let mut 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.allowed); + + // 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.allowed); + } + + #[tokio::test] + async fn test_on_error_ignore_continues_without_disabling() { + let mut 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.allowed); + + // 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); + } + + #[tokio::test] + async fn test_on_error_fail_halts_pipeline() { + let mut 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.allowed); + 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<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for TransformPlugin { + fn handle( + &self, + payload: &TestPayload, + _extensions: &FilteredExtensions, + _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: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + 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 mut 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.allowed); + let final_payload = result.payload.unwrap(); + let typed = final_payload.as_any().downcast_ref::().unwrap(); + assert_eq!(typed.value, "original_transformed"); + } + + #[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: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + // 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 mut 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.allowed); + 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()); + } + + #[tokio::test] + async fn test_timeout_fires_on_slow_handler() { + // Create a manager with a very short timeout + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 1, + short_circuit_on_deny: true, + }, + }; + let mut 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.allowed); + 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: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + 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 mut 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 = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + + // Pipeline should return immediately — before the background task finishes + assert!(result.allowed); + assert!(!TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task completed before pipeline returned"); + + // Wait for the background task to finish + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!(TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task never completed"); + } + + #[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: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + 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: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + 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 mut 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.allowed); + 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: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + // Increment a counter in local_state + 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 mut 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.allowed); + + // Check call_count = 1 in the returned context table + let table = &result1.context_table; + let ctx = table.values().next().expect("context table should have one entry"); + assert_eq!(ctx.get_local("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.allowed); + + // call_count should now be 2 — local_state persisted across invocations + let table2 = &result2.context_table; + let ctx2 = table2.values().next().expect("context table should have one entry"); + assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); + } +} diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs new file mode 100644 index 00000000..9d4a00a6 --- /dev/null +++ b/crates/cpex-core/src/plugin.rs @@ -0,0 +1,414 @@ +// 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; + +// --------------------------------------------------------------------------- +// Plugin Trait +// --------------------------------------------------------------------------- + +/// 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<(), PluginError> { Ok(()) } +/// async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +/// } +/// +/// impl CmfHookHandler for MyPlugin { +/// fn cmf_hook(&self, payload: MessagePayload, ext: &FilteredExtensions, 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. + async fn initialize(&self) -> Result<(), PluginError>; + + /// Graceful shutdown. + /// + /// Called once during teardown. Use this to flush buffers, close + /// connections, or release resources. + async fn shutdown(&self) -> Result<(), PluginError>; +} + +// --------------------------------------------------------------------------- +// Plugin Configuration +// --------------------------------------------------------------------------- + +/// 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, +} + +fn default_priority() -> i32 { + 100 +} + +// --------------------------------------------------------------------------- +// Plugin Condition (legacy scoping) +// --------------------------------------------------------------------------- + +/// 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>, +} + +impl PluginCondition { + /// Whether this condition matches the given context. + /// + /// A field that is `None` is treated as "any" (no restriction). + /// A field that is `Some(set)` matches if the given value is in the set. + /// All specified fields must match (AND semantics). + pub fn matches( + &self, + server_id: Option<&str>, + tenant_id: Option<&str>, + tool: Option<&str>, + prompt: Option<&str>, + resource: Option<&str>, + agent: Option<&str>, + ) -> bool { + let check = |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 + }, + } + }; + + check(&self.server_ids, server_id) + && check(&self.tenant_ids, tenant_id) + && check(&self.tools, tool) + && check(&self.prompts, prompt) + && check(&self.resources, resource) + && check(&self.agents, agent) + } +} + +// --------------------------------------------------------------------------- +// Plugin Mode +// --------------------------------------------------------------------------- + +/// 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")] +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 Mode +// --------------------------------------------------------------------------- + +/// 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")] +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 new file mode 100644 index 00000000..cbc88a9c --- /dev/null +++ b/crates/cpex-core/src/registry.rs @@ -0,0 +1,625 @@ +// 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 crate::context::PluginContext; +use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::trait_def::HookTypeDef; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig, PluginMode}; + +// --------------------------------------------------------------------------- +// Plugin Ref — trusted wrapper +// --------------------------------------------------------------------------- + +/// 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. + id: String, + + /// 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 { + let id = uuid::Uuid::new_v4().to_string(); + Self { + plugin, + trusted_config, + id, + 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. + pub fn id(&self) -> &str { + &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. + pub fn mode(&self) -> PluginMode { + if self.disabled.load(Ordering::Relaxed) { + 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 instantly visible across the system. + pub fn disable(&self) { + self.disabled.store(true, Ordering::Relaxed); + } + + /// Whether this plugin has been runtime-disabled. + pub fn is_disabled(&self) -> bool { + self.disabled.load(Ordering::Relaxed) + } + + /// Convenience: plugin priority from the trusted config. + pub fn priority(&self) -> i32 { + self.trusted_config.priority + } +} + +// --------------------------------------------------------------------------- +// Type-Erased Hook Handler +// --------------------------------------------------------------------------- + +/// 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: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, crate::error::PluginError>; + + /// The hook type name this handler was registered for. + fn hook_type_name(&self) -> &'static str; +} + +// --------------------------------------------------------------------------- +// Hook Entry — PluginRef + handler paired together +// --------------------------------------------------------------------------- + +/// A registered hook handler paired with its PluginRef. +/// +/// The executor uses `plugin_ref` for scheduling decisions (mode, +/// priority, capabilities) and `handler` for actual dispatch. +#[derive(Clone)] +pub struct HookEntry { + /// The plugin wrapper with authoritative config. + pub plugin_ref: PluginRef, + + /// The type-erased handler for this specific hook. + pub handler: Arc, +} + +// --------------------------------------------------------------------------- +// Plugin Registry +// --------------------------------------------------------------------------- + +/// 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.). +pub struct PluginRegistry { + /// Plugins keyed by name (for lookup and lifecycle). + 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) + } + + /// 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 = 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: plugin_ref.clone(), + 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 PluginRef if found. + pub fn unregister(&mut self, name: &str) -> Option { + let plugin_ref = self.plugins.remove(name)?; + + // Remove from hook index + for entries in self.hook_index.values_mut() { + entries.retain(|e| e.plugin_ref.name() != name); + } + + // Clean up empty hook entries + self.hook_index.retain(|_, entries| !entries.is_empty()); + + Some(plugin_ref) + } + + /// Look up a PluginRef by name. + pub fn get(&self, name: &str) -> Option<&PluginRef> { + self.plugins.get(name) + } + + /// 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. + pub fn plugin_names(&self) -> Vec<&str> { + self.plugins.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Group HookEntries by mode (used by the executor) +// --------------------------------------------------------------------------- + +/// 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 fn group_by_mode( + entries: &[HookEntry], +) -> ( + Vec, + Vec, + Vec, + Vec, + Vec, +) { + 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)] + 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: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + 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<(), PluginError> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + 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 = FilteredExtensions::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-sdk/Cargo.toml b/crates/cpex-sdk/Cargo.toml new file mode 100644 index 00000000..1077a33f --- /dev/null +++ b/crates/cpex-sdk/Cargo.toml @@ -0,0 +1,22 @@ +# 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 + +[dependencies] +cpex-core = { path = "../cpex-core" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs new file mode 100644 index 00000000..6992d196 --- /dev/null +++ b/crates/cpex-sdk/src/lib.rs @@ -0,0 +1,28 @@ +// 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, FilteredExtensions, 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; From 80172a169993ee178afa7b15b192da7647fc309f Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 4 May 2026 12:43:49 -0600 Subject: [PATCH 02/64] feat: CPEX Rust config (#38) * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor --- Cargo.lock | 9 + Cargo.toml | 1 + crates/cpex-core/Cargo.toml | 1 + crates/cpex-core/examples/README.md | 43 + crates/cpex-core/examples/plugin_demo.rs | 394 ++++++ crates/cpex-core/examples/plugin_demo.yaml | 59 + crates/cpex-core/src/config.rs | 1158 ++++++++++++++++- crates/cpex-core/src/error.rs | 30 + crates/cpex-core/src/executor.rs | 184 ++- crates/cpex-core/src/factory.rs | 141 +++ crates/cpex-core/src/hooks/payload.rs | 46 +- crates/cpex-core/src/lib.rs | 2 + crates/cpex-core/src/manager.rs | 1334 +++++++++++++++++++- crates/cpex-core/src/plugin.rs | 10 +- crates/cpex-core/src/registry.rs | 60 + 15 files changed, 3371 insertions(+), 101 deletions(-) create mode 100644 crates/cpex-core/examples/README.md create mode 100644 crates/cpex-core/examples/plugin_demo.rs create mode 100644 crates/cpex-core/examples/plugin_demo.yaml create mode 100644 crates/cpex-core/src/factory.rs diff --git a/Cargo.lock b/Cargo.lock index b06faa5a..8760f602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.102" @@ -49,6 +55,7 @@ version = "0.1.0" dependencies = [ "async-trait", "futures", + "hashbrown 0.15.5", "serde", "serde_json", "serde_yaml", @@ -197,6 +204,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] diff --git a/Cargo.toml b/Cargo.toml index 03fcb104..8ee43bc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ tracing = "0.1" uuid = { version = "1", features = ["v4"] } paste = "1" futures = "0.3" +hashbrown = "0.15" diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 4e0d4006..1a6d3351 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -25,3 +25,4 @@ thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } futures = { workspace = true } +hashbrown = { workspace = true } diff --git a/crates/cpex-core/examples/README.md b/crates/cpex-core/examples/README.md new file mode 100644 index 00000000..9be92c2d --- /dev/null +++ b/crates/cpex-core/examples/README.md @@ -0,0 +1,43 @@ +# 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 diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs new file mode 100644 index 00000000..8e5fb602 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -0,0 +1,394 @@ +// 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, FilteredExtensions, 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<(), PluginError> { + println!(" [identity-resolver] initialized"); + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + println!(" [identity-resolver] shutdown"); + Ok(()) + } +} + +impl HookHandler for IdentityResolver { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _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 { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _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 { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + 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 { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!(" [audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, payload.tool_name, payload.arguments); + PluginResult::allow() + } +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!(" [audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, payload.tool_name); + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// 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))), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// Step 4: Build extensions with MetaExtension for routing +// --------------------------------------------------------------------------- + +fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { + Extensions { + meta: Some(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 mut 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.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 global_state = std::collections::HashMap::new(); + global_state.insert( + "pii_clearance".into(), + serde_json::Value::Bool(true), + ); + // Pass global state via context table + let mut ctx_table = cpex_core::context::PluginContextTable::new(); + // We need to seed global_state — create a dummy entry + ctx_table.insert( + "__seed__".into(), + cpex_core::context::PluginContext::with_global_state(global_state), + ); + 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: No user identity --- + println!("=== Scenario 5: 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 new file mode 100644 index 00000000..9e3dd610 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.yaml @@ -0,0 +1,59 @@ +# 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] + +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 + + - 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 + + # Wildcard — catch-all for unmatched tools + - tool: "*" + plugins: + - audit-logger diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 02496747..375094e5 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -5,11 +5,1157 @@ // // Unified YAML configuration parsing. // -// Parses the unified config format that combines global settings, -// plugin declarations, named policy groups, and per-entity routes -// into a single YAML document. +// Parses the config format that combines global settings, plugin +// declarations, and per-entity routes into a single YAML document. // -// Mirrors the unified config proposal in -// apl-plugins/docs/unified-config-proposal.md. +// 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, Serialize}; + +use crate::error::PluginError; +use crate::plugin::PluginConfig; + +// --------------------------------------------------------------------------- +// Top-Level Config +// --------------------------------------------------------------------------- + +/// 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, + + /// 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 + } +} + +// --------------------------------------------------------------------------- +// Plugin Settings +// --------------------------------------------------------------------------- + +/// 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, +} + +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, + } + } +} + +fn default_timeout() -> u64 { + 30 +} + +fn default_true() -> bool { + true +} + +// --------------------------------------------------------------------------- +// Global Config +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// Policy Group +// --------------------------------------------------------------------------- + +/// 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)] + pub plugins: Vec, +} + +// --------------------------------------------------------------------------- +// Plugin Ref (route/group plugin reference) +// --------------------------------------------------------------------------- + +/// 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 PluginRef { + /// Just the name — activate the plugin with no config overrides. + Name(String), + /// Name with config overrides — single-key map. + WithOverrides(HashMap), +} + +impl PluginRef { + /// 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(), + } + } +} + +// --------------------------------------------------------------------------- +// Route Entry +// --------------------------------------------------------------------------- + +/// 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, + + /// 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)] + pub plugins: Vec, +} + +// --------------------------------------------------------------------------- +// Route Meta +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// String or List (for tool matching) +// --------------------------------------------------------------------------- + +/// 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). + Single(String), + /// List of exact names. + List(Vec), +} + +impl Default for StringOrList { + fn default() -> Self { + Self::Single(String::new()) + } +} + +impl StringOrList { + /// Check if this matcher matches the given name. + pub fn matches(&self, name: &str) -> bool { + match self { + Self::Single(pattern) => { + if pattern == "*" { + true + } else if pattern.contains('*') { + let prefix = pattern.trim_end_matches('*'); + name.starts_with(prefix) + } else { + name == pattern + } + } + Self::List(names) => names.iter().any(|n| n == name), + } + } +} + +// --------------------------------------------------------------------------- +// Config Loading +// --------------------------------------------------------------------------- + +/// 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 { + let config: CpexConfig = + serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { + message: format!("failed to parse config YAML: {}", e), + })?; + validate_config(&config)?; + Ok(config) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate a parsed config for structural correctness. +fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { + let mut seen_names = HashSet::new(); + for plugin in &config.plugins { + if !seen_names.insert(&plugin.name) { + return Err(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(PluginError::Config { + message: format!( + "route {} has no entity matcher (need tool, resource, prompt, or llm)", + i + ), + }); + } + if count > 1 { + return Err(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(PluginError::Config { + message: format!("route {} references unknown plugin '{}'", i, plugin_ref.name()), + }); + } + } + } + + for (group_name, group) in &config.global.policies { + for plugin_ref in &group.plugins { + if !plugin_names.contains(plugin_ref.name()) { + return Err(PluginError::Config { + message: format!( + "policy group '{}' references unknown plugin '{}'", + group_name, + plugin_ref.name() + ), + }); + } + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Route Resolution +// --------------------------------------------------------------------------- + +/// 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; + +/// 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 policy 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 tags + host's runtime tags + let mut merged_tags: HashSet = request_tags.clone(); + if let Some(meta) = &route.meta { + for tag in &meta.tags { + merged_tags.insert(tag.clone()); + } + } + + // 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 +} + +/// 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: &[PluginRef], + 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 { + // Check scope compatibility + 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 base_specificity = match entity_type { + "tool" => { + if let Some(matcher) = &route.tool { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + "resource" => { + if let Some(matcher) = &route.resource { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + "prompt" => { + if let Some(matcher) = &route.prompt { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + "llm" => { + if let Some(matcher) = &route.llm { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + _ => continue, + }; + + let when_bonus = if route.when.is_some() { SPECIFICITY_WHEN_ONLY } else { 0 }; + let total = base_specificity + scope_bonus + when_bonus; + + if best.map_or(true, |(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_matches() { + let matcher = StringOrList::Single("hr-*".to_string()); + assert!(matcher.matches("hr-compensation")); + assert!(matcher.matches("hr-benefits")); + assert!(!matcher.matches("finance-report")); + } + + #[test] + fn test_wildcard_matches_everything() { + let matcher = StringOrList::Single("*".to_string()); + assert!(matcher.matches("anything")); + } + + #[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()); -// TODO: Implement CpexConfig, GlobalConfig, RouteEntry serde models + // 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")); + } +} diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs index 4b684d54..fd253429 100644 --- a/crates/cpex-core/src/error.rs +++ b/crates/cpex-core/src/error.rs @@ -24,6 +24,12 @@ use thiserror::Error; /// 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. @@ -31,8 +37,17 @@ pub enum PluginError { 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. @@ -40,6 +55,8 @@ pub enum PluginError { 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). @@ -93,6 +110,12 @@ pub struct PluginViolation { /// 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 { @@ -104,6 +127,7 @@ impl PluginViolation { description: None, details: HashMap::new(), plugin_name: None, + proto_error_code: None, } } @@ -118,6 +142,12 @@ impl PluginViolation { 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 { diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 4b1188ef..9a6cdcd2 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -26,6 +26,7 @@ use std::any::Any; use std::collections::HashMap; +use std::fmt; use std::sync::Arc; use std::time::Duration; @@ -67,24 +68,35 @@ impl Default for ExecutorConfig { /// Aggregate result from a full hook invocation across all phases. /// /// Wraps the final payload, extensions, any violation, and the -/// context table. The caller should pass `context_table` into the -/// next hook invocation to preserve per-plugin local state across -/// hooks in the same request lifecycle. +/// 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. #[derive(Debug)] pub struct PipelineResult { - /// Whether the pipeline completed without a deny. - pub allowed: bool, + /// 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. - pub payload: Option>, + pub modified_payload: Option>, /// The final extensions after all modifications. - pub extensions: Extensions, + /// `None` if no plugin modified extensions. + pub modified_extensions: Option, /// The violation that caused a deny, if any. pub violation: Option, + /// 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, @@ -98,10 +110,11 @@ impl PipelineResult { context_table: PluginContextTable, ) -> Self { Self { - allowed: true, - payload: Some(payload), - extensions, + continue_processing: true, + modified_payload: Some(payload), + modified_extensions: Some(extensions), violation: None, + metadata: None, context_table, } } @@ -113,13 +126,91 @@ impl PipelineResult { context_table: PluginContextTable, ) -> Self { Self { - allowed: false, - payload: None, - extensions, + continue_processing: false, + modified_payload: None, + modified_extensions: Some(extensions), violation: Some(violation), + metadata: None, context_table, } } + + /// Whether this result represents a denial. + pub fn is_denied(&self) -> bool { + !self.continue_processing + } +} + +// --------------------------------------------------------------------------- +// Background Tasks +// --------------------------------------------------------------------------- + +/// 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() + } } // --------------------------------------------------------------------------- @@ -158,19 +249,26 @@ impl Executor { /// /// # Returns /// - /// A `PipelineResult` with the final payload, extensions, violation, - /// and the updated context table for threading into the next hook. + /// 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, - ) -> PipelineResult { + ) -> (PipelineResult, BackgroundTasks) { let mut ctx_table = context_table.unwrap_or_default(); if entries.is_empty() { - return PipelineResult::allowed_with(payload, extensions, ctx_table); + return ( + PipelineResult::allowed_with(payload, extensions, ctx_table), + BackgroundTasks::empty(), + ); } // Group entries by mode (from trusted_config) @@ -193,7 +291,10 @@ impl Executor { ) .await { - return PipelineResult::denied(v, current_extensions, ctx_table); + return ( + PipelineResult::denied(v, current_extensions, ctx_table), + BackgroundTasks::empty(), + ); } // Phase 2: TRANSFORM — serial, chained, can modify, cannot block @@ -218,17 +319,23 @@ impl Executor { .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) .await { - return PipelineResult::denied(violation, current_extensions, ctx_table); + return ( + PipelineResult::denied(violation, current_extensions, ctx_table), + BackgroundTasks::empty(), + ); } // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results - self.spawn_fire_and_forget( + let bg_handles = self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, &ctx_table, ); - PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + ( + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table), + BackgroundTasks::from_handles(bg_handles), + ) } // ----------------------------------------------------------------------- @@ -547,14 +654,18 @@ impl Executor { /// 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, ctx_table: &PluginContextTable, - ) { + ) -> Vec<(String, tokio::task::JoinHandle<()>)> { if entries.is_empty() { - return; + return Vec::new(); } let timeout_dur = Duration::from_secs(self.config.timeout_seconds); @@ -564,14 +675,17 @@ impl Executor { .map(|c| c.global_state.clone()) .unwrap_or_default(); + 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(); let mut ctx = PluginContext::with_global_state(global_state.clone()); let dur = timeout_dur; + let name_for_log = plugin_name.clone(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { let filtered = FilteredExtensions::default(); let result = timeout( dur, @@ -582,14 +696,18 @@ impl Executor { match result { Ok(Ok(_)) => {} // discard Ok(Err(e)) => { - warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", plugin_name, e); + warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", name_for_log, e); } Err(_) => { - warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", plugin_name); + warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", name_for_log); } } }); + + handles.push((plugin_name, handle)); } + + handles } } @@ -719,8 +837,8 @@ mod tests { Extensions::default(), PluginContextTable::new(), ); - assert!(result.allowed); - assert!(result.payload.is_some()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); assert!(result.violation.is_none()); } @@ -732,8 +850,8 @@ mod tests { Extensions::default(), PluginContextTable::new(), ); - assert!(!result.allowed); - assert!(result.payload.is_none()); + assert!(!result.continue_processing); + assert!(result.modified_payload.is_none()); assert!(result.violation.is_some()); } @@ -743,10 +861,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let result = executor + let (result, _) = executor .execute(&[], payload, Extensions::default(), None) .await; - assert!(result.allowed); - assert!(result.payload.is_some()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); } } diff --git a/crates/cpex-core/src/factory.rs b/crates/cpex-core/src/factory.rs new file mode 100644 index 00000000..e77f80ca --- /dev/null +++ b/crates/cpex-core/src/factory.rs @@ -0,0 +1,141 @@ +// 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; + +// --------------------------------------------------------------------------- +// Plugin Factory Trait +// --------------------------------------------------------------------------- + +/// 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)>, +} + +// --------------------------------------------------------------------------- +// Plugin Factory Registry +// --------------------------------------------------------------------------- + +/// 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. + pub fn register( + &mut self, + kind: impl Into, + factory: Box, + ) { + self.factories.insert(kind.into(), factory); + } + + /// 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/payload.rs b/crates/cpex-core/src/hooks/payload.rs index c25f0247..f46d89c6 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -39,11 +39,16 @@ use serde::{Deserialize, Serialize}; /// This is a Phase 1 stub with minimal fields. Phase 3 adds the /// full CMF extension types (SecurityExtension with MonotonicSet, /// DelegationExtension with scope-narrowing chain, HttpExtension -/// with Guarded, MetaExtension, etc.). +/// with Guarded, etc.). /// /// Mirrors Python's `cpex.framework.extensions.Extensions`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Extensions { + /// Host-provided operational metadata — entity identification, + /// tags, scope, and arbitrary properties. Immutable. + #[serde(default)] + pub meta: Option, + /// Security labels (monotonic — add-only in the full implementation). #[serde(default)] pub labels: std::collections::HashSet, @@ -53,6 +58,42 @@ pub struct Extensions { pub custom: HashMap, } +/// Host-provided operational metadata about the entity being processed. +/// +/// Carries entity identification (type + name) for route resolution, +/// operational tags for policy group inheritance, scope for host-defined +/// grouping, and arbitrary properties for policy conditions. +/// +/// Immutable — set by the host before invoking the hook. Plugins +/// can read but not modify. +/// +/// Mirrors Python's `cpex.framework.extensions.meta.MetaExtension`. +#[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: std::collections::HashSet, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + #[serde(default)] + pub scope: Option, + + /// Arbitrary key-value metadata. + #[serde(default)] + pub properties: HashMap, +} + /// Capability-filtered view of Extensions for a specific plugin. /// /// Built by the framework before dispatching to each plugin. Fields @@ -63,6 +104,9 @@ pub struct Extensions { /// the Python `filter_extensions()` implementation. #[derive(Debug, Clone, Default)] pub struct FilteredExtensions { + /// Meta extension (always visible — immutable, no capability needed). + pub meta: Option, + /// Security labels (visible with `read_labels` capability). pub labels: Option>, diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index 2743b238..fbede921 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -17,6 +17,7 @@ // - [`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) // - [`error`] — Error types, violations, and result types @@ -24,6 +25,7 @@ pub mod config; pub mod context; pub mod error; pub mod executor; +pub mod factory; pub mod hooks; pub mod manager; pub mod plugin; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index a2a6e8a3..0810bbba 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -24,13 +24,18 @@ // Mirrors the Python framework's PluginManager in // cpex/framework/manager.py. -use std::sync::Arc; +use std::hash::{Hash, Hasher}; +use std::path::Path; +use std::sync::{Arc, RwLock}; -use tracing::{error, info}; +use hashbrown::HashMap; +use tracing::{error, info, warn}; +use crate::config::{self, CpexConfig}; use crate::context::PluginContextTable; use crate::error::PluginError; -use crate::executor::{Executor, ExecutorConfig, PipelineResult}; +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}; @@ -90,6 +95,44 @@ impl Default for ManagerConfig { /// 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 {} + + pub struct PluginManager { /// Plugin registry — stores PluginRefs and hook-to-handler mappings. registry: PluginRegistry, @@ -97,6 +140,23 @@ pub struct PluginManager { /// Executor — stateless 5-phase pipeline engine. executor: Executor, + /// Parsed CPEX config (when loaded from file). Used for route resolution. + cpex_config: Option, + + /// Factory registry — owned by the manager. Used for initial + /// instantiation and for creating override instances when routes + /// override a plugin's base config. + factories: PluginFactoryRegistry, + + /// 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, + + /// Whether initialize() has been called. initialized: bool, } @@ -104,13 +164,160 @@ pub struct PluginManager { impl PluginManager { /// Create a new PluginManager with the given configuration. pub fn new(config: ManagerConfig) -> Self { + let cache_hasher = hashbrown::DefaultHashBuilder::default(); Self { registry: PluginRegistry::new(), executor: Executor::new(config.executor), + cpex_config: None, + factories: PluginFactoryRegistry::new(), + route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())), + cache_hasher, initialized: false, } } + // ----------------------------------------------------------------------- + // Factory Registration + // ----------------------------------------------------------------------- + + /// 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( + &mut self, + kind: impl Into, + factory: Box, + ) { + self.factories.register(kind, factory); + } + + // ----------------------------------------------------------------------- + // Config Loading + // ----------------------------------------------------------------------- + + /// 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(&mut self, path: &Path) -> Result<(), PluginError> { + 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(&mut self, cpex_config: CpexConfig) -> Result<(), PluginError> { + // Update executor settings from config + self.executor = Executor::new(ExecutorConfig { + timeout_seconds: cpex_config.plugin_settings.plugin_timeout, + short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, + }); + + // Instantiate and register each plugin from config + for plugin_config in &cpex_config.plugins { + let factory = self.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)?; + + self.registry + .register_multi_handler( + instance.plugin, + plugin_config.clone(), + instance.handlers, + ) + .map_err(|msg| PluginError::Config { message: msg })?; + + info!( + "Registered plugin '{}' (kind: '{}') for hooks: {:?}", + plugin_config.name, plugin_config.kind, plugin_config.hooks + ); + } + + // Clear routing cache — config changed + self.clear_routing_cache(); + + // Store config for route resolution + self.cpex_config = Some(cpex_config); + + 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 { + let mut manager = Self::new(ManagerConfig::default()); + + // Instantiate and register each plugin + for plugin_config in &cpex_config.plugins { + 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)?; + + manager + .registry + .register_multi_handler( + instance.plugin, + plugin_config.clone(), + instance.handlers, + ) + .map_err(|msg| PluginError::Config { message: msg })?; + } + + // Update executor from config settings + manager.executor = Executor::new(ExecutorConfig { + timeout_seconds: cpex_config.plugin_settings.plugin_timeout, + short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, + }); + + manager.cpex_config = Some(cpex_config); + Ok(manager) + } + // ----------------------------------------------------------------------- // Registration // ----------------------------------------------------------------------- @@ -244,6 +451,9 @@ impl PluginManager { plugin_name, message: format!("initialization failed: {}", e), source: Some(Box::new(e)), + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, }); } @@ -304,33 +514,51 @@ impl PluginManager { /// /// # Returns /// - /// A `PipelineResult` with the final payload, extensions, violation, - /// and the updated context table. + /// 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 { + ) -> (PipelineResult, BackgroundTasks) { let hook_type = HookType::new(hook_name); - let entries = self.registry.entries_for_hook(&hook_type); + let all_entries = self.registry.entries_for_hook(&hook_type); + + if all_entries.is_empty() { + return ( + PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); if entries.is_empty() { - return PipelineResult::allowed_with( - payload, - extensions, - context_table.unwrap_or_default(), + return ( + PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), ); } self.executor - .execute(entries, payload, extensions, context_table) + .execute(&entries, payload, extensions, context_table) .await } // ----------------------------------------------------------------------- // Hook Invocation — Typed (invoke::) + // ----------------------------------------------------------------------- /// Invoke a typed hook. @@ -340,6 +568,11 @@ impl PluginManager { /// 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`). @@ -347,38 +580,239 @@ impl PluginManager { /// # Arguments /// /// * `payload` — the typed payload. - /// * `extensions` — the full extensions. + /// * `extensions` — the full extensions (includes meta for routing). /// * `context_table` — optional context table from a previous hook. /// /// # Returns /// - /// A `PipelineResult` with the final payload (type-erased — - /// caller downcasts via `as_any()`), extensions, violation, and - /// the updated context table. + /// A tuple of `(PipelineResult, BackgroundTasks)`. pub async fn invoke( &self, payload: H::Payload, extensions: Extensions, context_table: Option, - ) -> PipelineResult { + ) -> (PipelineResult, BackgroundTasks) { let hook_type = HookType::new(H::NAME); - let entries = self.registry.entries_for_hook(&hook_type); + let all_entries = self.registry.entries_for_hook(&hook_type); + + if all_entries.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(all_entries, &extensions, H::NAME); if entries.is_empty() { let boxed: Box = Box::new(payload); - return PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), + return ( + PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), ); } let boxed: Box = Box::new(payload); self.executor - .execute(entries, boxed, extensions, context_table) + .execute(&entries, boxed, extensions, context_table) .await } + // ----------------------------------------------------------------------- + // Route Filtering + // ----------------------------------------------------------------------- + + /// 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. + fn filter_entries_by_route( + &self, + entries: &[crate::registry::HookEntry], + extensions: &Extensions, + hook_name: &str, + ) -> Arc> { + // If no config or routing disabled, return all + let cpex_config = match &self.cpex_config { + Some(c) if c.routing_enabled() => c, + _ => return Arc::new(entries.to_vec()), + }; + + // Extract entity info from meta extension + 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() + }; + { + let cache = self.route_cache.read().unwrap(); + 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) + let resolved = 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) { + filtered.push(override_entry); + continue; + } + } + filtered.push(entry.clone()); + } + } + + let cached = Arc::new(filtered); + + // Store in cache — owned key allocated only on cache miss + 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(), + }; + { + let mut cache = self.route_cache.write().unwrap(); + cache.insert(cache_key, Arc::clone(&cached)); + } + + cached + } + + /// 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. Returns + /// None if no factory is available for the plugin's kind. + 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; + + let factory = self.factories.get(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 + let target_hook = base_entry.handler.hook_type_name(); + match factory.create(&merged_config) { + Ok(instance) => { + // Find the handler matching the current hook + let handler = instance + .handlers + .into_iter() + .find(|(name, _)| *name == target_hook) + .map(|(_, h)| h); + + if let Some(handler) = handler { + let plugin_ref = + crate::registry::PluginRef::new(instance.plugin, merged_config); + Some(crate::registry::HookEntry { + plugin_ref, + handler, + }) + } else { + warn!( + "Override instance for '{}' has no handler for hook '{}'", + base_config.name, target_hook + ); + None + } + } + Err(e) => { + error!( + "Failed to create override instance for '{}': {}", + base_config.name, e + ); + None // fall back to base instance + } + } + } + + /// Clear the routing cache. Call when config is reloaded or + /// plugins are registered/unregistered. + pub fn clear_routing_cache(&self) { + let mut cache = self.route_cache.write().unwrap(); + cache.clear(); + } + + /// Number of entries in the routing cache. + pub fn routing_cache_size(&self) -> usize { + self.route_cache.read().unwrap().len() + } + // ----------------------------------------------------------------------- // Query Methods // ----------------------------------------------------------------------- @@ -511,6 +945,9 @@ mod tests { plugin_name: "error-plugin".into(), message: "simulated failure".into(), source: None, + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, }) } @@ -574,12 +1011,12 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(result.allowed); - assert!(result.payload.is_some()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); } #[tokio::test] @@ -597,11 +1034,11 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -618,11 +1055,11 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(!result.allowed); + assert!(!result.continue_processing); assert_eq!(result.violation.as_ref().unwrap().code, "denied"); } @@ -640,11 +1077,11 @@ mod tests { }; - let result = mgr + let (result, _) = mgr .invoke::(payload, Extensions::default(), None) .await; - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -687,12 +1124,12 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; // Audit mode — deny is suppressed, pipeline continues - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -718,8 +1155,8 @@ mod tests { // 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.allowed); + 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(); @@ -729,8 +1166,8 @@ mod tests { // 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.allowed); + let (result2, _) = mgr.invoke_by_name("test_hook", payload2, Extensions::default(), None).await; + assert!(result2.continue_processing); } #[tokio::test] @@ -750,8 +1187,8 @@ mod tests { // 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.allowed); + 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(); @@ -776,8 +1213,8 @@ mod tests { // 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.allowed); + 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(), @@ -848,10 +1285,10 @@ mod tests { let payload = TestPayload { value: "original".into() }; - let result = mgr.invoke::(payload, Extensions::default(), None).await; + let (result, _) = mgr.invoke::(payload, Extensions::default(), None).await; - assert!(result.allowed); - let final_payload = result.payload.unwrap(); + assert!(result.continue_processing); + let final_payload = result.modified_payload.unwrap(); let typed = final_payload.as_any().downcast_ref::().unwrap(); assert_eq!(typed.value, "original_transformed"); } @@ -902,10 +1339,10 @@ mod tests { 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 (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; let elapsed = start.elapsed(); - assert!(result.allowed); + 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()); @@ -932,11 +1369,11 @@ mod tests { 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 (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.allowed); + 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()); @@ -980,14 +1417,15 @@ mod tests { 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; + 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.allowed); + assert!(result.continue_processing); assert!(!TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task completed before pipeline returned"); - // Wait for the background task to finish - tokio::time::sleep(std::time::Duration::from_millis(300)).await; + // 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"); } @@ -1052,9 +1490,9 @@ mod tests { 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; + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result.allowed); + assert!(result.continue_processing); assert!( saw_writer.load(std::sync::atomic::Ordering::SeqCst), "reader plugin did not see writer's global_state change" @@ -1098,8 +1536,8 @@ mod tests { // 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.allowed); + 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; @@ -1108,14 +1546,792 @@ mod tests { // 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( + let (result2, _) = mgr.invoke_by_name( "test_hook", payload2, Extensions::default(), Some(result1.context_table), ).await; - assert!(result2.allowed); + assert!(result2.continue_processing); // call_count should now be 2 — local_state persisted across invocations let table2 = &result2.context_table; let ctx2 = table2.values().next().expect("context table should have one entry"); assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); } + + // -- 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 mut 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 mut 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 mut 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 mut 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(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(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 + } + + #[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 mut 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(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(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 mut 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(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_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 mut 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(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(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 mut 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(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); + } + + #[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 mut 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(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 mut 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 mut 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 mut 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; + // denier has default priority 100, allower has default 100 — order depends on registration + // but at least both fire (not filtered by routing) + // We can't assert allow/deny specifically since both run — just check it executed + assert!(result.continue_processing || !result.continue_processing); // both plugins fired + } + + #[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 mut 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 mut 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 mut 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); + } } diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs index 9d4a00a6..b9c13f11 100644 --- a/crates/cpex-core/src/plugin.rs +++ b/crates/cpex-core/src/plugin.rs @@ -89,13 +89,19 @@ pub trait Plugin: Send + Sync { /// /// Called before any hook invocations. Use this to establish /// connections, load resources, or validate configuration. - async fn initialize(&self) -> Result<(), PluginError>; + /// Default implementation does nothing. + async fn initialize(&self) -> Result<(), PluginError> { + Ok(()) + } /// Graceful shutdown. /// /// Called once during teardown. Use this to flush buffers, close /// connections, or release resources. - async fn shutdown(&self) -> Result<(), PluginError>; + /// Default implementation does nothing. + async fn shutdown(&self) -> Result<(), PluginError> { + Ok(()) + } } // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index cbc88a9c..fce0466c 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -278,6 +278,66 @@ impl PluginRegistry { 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 = PluginRef::new(plugin, config); + + for (hook_name, handler) in &handlers { + let hook_type = HookType::new(*hook_name); + let entry = HookEntry { + plugin_ref: plugin_ref.clone(), + 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, From 5176312111335e0b25099c15e5159e53c054b9c9 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 4 May 2026 12:58:52 -0600 Subject: [PATCH 03/64] feat: RUST with CMF and extensions. (#44) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor * feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor * feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor * feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Teryl Taylor Co-authored-by: Frederico Araujo --- Cargo.toml | 2 +- crates/cpex-core/examples/README.md | 37 + .../examples/cmf_capabilities_demo.rs | 435 +++++++++ .../examples/cmf_capabilities_demo.yaml | 50 ++ crates/cpex-core/examples/plugin_demo.rs | 16 +- crates/cpex-core/src/cmf/constants.rs | 65 ++ crates/cpex-core/src/cmf/content.rs | 486 ++++++++++ crates/cpex-core/src/cmf/enums.rs | 176 ++++ crates/cpex-core/src/cmf/message.rs | 462 ++++++++++ crates/cpex-core/src/cmf/mod.rs | 100 +++ crates/cpex-core/src/cmf/view.rs | 848 ++++++++++++++++++ crates/cpex-core/src/executor.rs | 116 ++- crates/cpex-core/src/extensions/agent.rs | 60 ++ crates/cpex-core/src/extensions/completion.rs | 71 ++ crates/cpex-core/src/extensions/container.rs | 532 +++++++++++ crates/cpex-core/src/extensions/delegation.rs | 161 ++++ crates/cpex-core/src/extensions/filter.rs | 549 ++++++++++++ crates/cpex-core/src/extensions/framework.rs | 38 + crates/cpex-core/src/extensions/guarded.rs | 141 +++ crates/cpex-core/src/extensions/http.rs | 200 +++++ crates/cpex-core/src/extensions/llm.rs | 27 + crates/cpex-core/src/extensions/mcp.rs | 115 +++ crates/cpex-core/src/extensions/meta.rs | 45 + crates/cpex-core/src/extensions/mod.rs | 52 ++ crates/cpex-core/src/extensions/monotonic.rs | 183 ++++ crates/cpex-core/src/extensions/provenance.rs | 27 + crates/cpex-core/src/extensions/request.rs | 35 + crates/cpex-core/src/extensions/security.rs | 337 +++++++ crates/cpex-core/src/extensions/tiers.rs | 100 +++ crates/cpex-core/src/hooks/adapter.rs | 4 +- crates/cpex-core/src/hooks/mod.rs | 4 +- crates/cpex-core/src/hooks/payload.rs | 100 +-- crates/cpex-core/src/hooks/trait_def.rs | 22 +- crates/cpex-core/src/lib.rs | 3 + crates/cpex-core/src/manager.rs | 371 +++++++- crates/cpex-core/src/plugin.rs | 2 +- crates/cpex-core/src/registry.rs | 8 +- crates/cpex-sdk/src/lib.rs | 13 +- 38 files changed, 5826 insertions(+), 167 deletions(-) create mode 100644 crates/cpex-core/examples/cmf_capabilities_demo.rs create mode 100644 crates/cpex-core/examples/cmf_capabilities_demo.yaml create mode 100644 crates/cpex-core/src/cmf/constants.rs create mode 100644 crates/cpex-core/src/cmf/content.rs create mode 100644 crates/cpex-core/src/cmf/enums.rs create mode 100644 crates/cpex-core/src/cmf/message.rs create mode 100644 crates/cpex-core/src/cmf/mod.rs create mode 100644 crates/cpex-core/src/cmf/view.rs create mode 100644 crates/cpex-core/src/extensions/agent.rs create mode 100644 crates/cpex-core/src/extensions/completion.rs create mode 100644 crates/cpex-core/src/extensions/container.rs create mode 100644 crates/cpex-core/src/extensions/delegation.rs create mode 100644 crates/cpex-core/src/extensions/filter.rs create mode 100644 crates/cpex-core/src/extensions/framework.rs create mode 100644 crates/cpex-core/src/extensions/guarded.rs create mode 100644 crates/cpex-core/src/extensions/http.rs create mode 100644 crates/cpex-core/src/extensions/llm.rs create mode 100644 crates/cpex-core/src/extensions/mcp.rs create mode 100644 crates/cpex-core/src/extensions/meta.rs create mode 100644 crates/cpex-core/src/extensions/mod.rs create mode 100644 crates/cpex-core/src/extensions/monotonic.rs create mode 100644 crates/cpex-core/src/extensions/provenance.rs create mode 100644 crates/cpex-core/src/extensions/request.rs create mode 100644 crates/cpex-core/src/extensions/security.rs create mode 100644 crates/cpex-core/src/extensions/tiers.rs diff --git a/Cargo.toml b/Cargo.toml index 8ee43bc0..47acca9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ authors = ["Teryl Taylor"] [workspace.dependencies] tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive", "rc"] } serde_yaml = "0.9" serde_json = "1" async-trait = "0.1" diff --git a/crates/cpex-core/examples/README.md b/crates/cpex-core/examples/README.md index 9be92c2d..c3962e31 100644 --- a/crates/cpex-core/examples/README.md +++ b/crates/cpex-core/examples/README.md @@ -41,3 +41,40 @@ The demo runs five scenarios against three registered plugins: - `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 new file mode 100644 index 00000000..1257f03d --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -0,0 +1,435 @@ +// 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::{ContentPart, CmfHook, 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 { + 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 { + 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 { + 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 mut 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 new file mode 100644 index 00000000..ace0f5cb --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.yaml @@ -0,0 +1,50 @@ +# 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 index 8e5fb602..637eab88 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -17,7 +17,7 @@ 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, FilteredExtensions, MetaExtension}; +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}; @@ -77,7 +77,7 @@ impl HookHandler for IdentityResolver { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { if payload.user.is_empty() { @@ -95,7 +95,7 @@ impl HookHandler for IdentityResolver { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { println!(" [identity-resolver] post-invoke: user '{}' completed '{}'", @@ -119,7 +119,7 @@ impl HookHandler for PiiGuard { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> PluginResult { // Check if the user has PII clearance (simulated via context) @@ -156,7 +156,7 @@ impl HookHandler for AuditLogger { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { println!(" [audit-logger] LOG: user='{}' tool='{}' args='{}'", @@ -169,7 +169,7 @@ impl HookHandler for AuditLogger { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { println!(" [audit-logger] LOG: post-invoke user='{}' tool='{}'", @@ -229,12 +229,12 @@ impl PluginFactory for AuditLoggerFactory { fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { Extensions { - meta: Some(MetaExtension { + 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() } } diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs new file mode 100644 index 00000000..12a8ac5e --- /dev/null +++ b/crates/cpex-core/src/cmf/constants.rs @@ -0,0 +1,65 @@ +// 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"; + +// --------------------------------------------------------------------------- +// Serialization field names for MessageView::to_dict() / to_opa_input() +// --------------------------------------------------------------------------- + +// 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"; diff --git a/crates/cpex-core/src/cmf/content.rs b/crates/cpex-core/src/cmf/content.rs new file mode 100644 index 00000000..3cde9b65 --- /dev/null +++ b/crates/cpex-core/src/cmf/content.rs @@ -0,0 +1,486 @@ +// 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; + +// --------------------------------------------------------------------------- +// Domain Objects +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// Media Source Types +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// ContentPart — Tagged Enum +// --------------------------------------------------------------------------- + +/// 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 new file mode 100644 index 00000000..97f1dcc2 --- /dev/null +++ b/crates/cpex-core/src/cmf/enums.rs @@ -0,0 +1,176 @@ +// 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}; + +// --------------------------------------------------------------------------- +// Role +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// Channel +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// ContentType +// --------------------------------------------------------------------------- + +/// 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, +} + +// --------------------------------------------------------------------------- +// ResourceType +// --------------------------------------------------------------------------- + +/// 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 new file mode 100644 index 00000000..a8e700d5 --- /dev/null +++ b/crates/cpex-core/src/cmf/message.rs @@ -0,0 +1,462 @@ +// 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; + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +/// 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, + } + } + + /// Extract all text content from the message. + /// + /// Concatenates text from all `Text` content parts. + 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() + } +} + +// --------------------------------------------------------------------------- +// MessagePayload — PluginPayload wrapper +// --------------------------------------------------------------------------- + +/// 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); + +// --------------------------------------------------------------------------- +// CmfHook — Hook Type Definition +// --------------------------------------------------------------------------- + +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 new file mode 100644 index 00000000..11ae76a4 --- /dev/null +++ b/crates/cpex-core/src/cmf/mod.rs @@ -0,0 +1,100 @@ +// 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 new file mode 100644 index 00000000..3407b472 --- /dev/null +++ b/crates/cpex-core/src/cmf/view.rs @@ -0,0 +1,848 @@ +// 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; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/// 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) + } +} + +// --------------------------------------------------------------------------- +// MessageView +// --------------------------------------------------------------------------- + +/// 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.map_or(false, |h| h.contains("pre")) + } + + /// Whether this is a post-execution hook. + pub fn is_post(&self) -> bool { + self.hook.map_or(false, |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(|s| Some(s)).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() + } +} + +// --------------------------------------------------------------------------- +// iter_views — decompose a Message into views +// --------------------------------------------------------------------------- + +/// 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 std::sync::Arc; + use crate::extensions::{SecurityExtension, HttpExtension}; + + 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 std::sync::Arc; + use crate::extensions::{ + SecurityExtension, HttpExtension, RequestExtension, AgentExtension, + }; + + 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/executor.rs b/crates/cpex-core/src/executor.rs index 9a6cdcd2..20a80a86 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -34,7 +34,8 @@ use tokio::time::timeout; use tracing::{error, warn}; use crate::context::{PluginContext, PluginContextTable}; -use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::extensions::filter_extensions; +use crate::hooks::payload::{Extensions, PluginPayload, WriteToken}; use crate::plugin::OnError; use crate::registry::{group_by_mode, HookEntry}; @@ -329,6 +330,7 @@ impl Executor { let bg_handles = self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, + ¤t_extensions, &ctx_table, ); @@ -381,10 +383,30 @@ impl Executor { let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); ctx.global_state = global_state.clone(); - // TODO: Capability-filter extensions per plugin (Phase 3) - let filtered = FilteredExtensions::default(); + // 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 the payload + // 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; @@ -405,9 +427,33 @@ impl Executor { if let Some(mp) = erased.modified_payload { *payload = mp; } - if let Some(me) = erased.modified_extensions { - // TODO: Merge with tier validation (Phase 3) - *extensions = me; + if let Some(owned) = erased.modified_extensions { + // Validate tier constraints before accepting + if !extensions.validate_immutable(&owned) { + warn!( + "{} plugin '{}' violated immutable tier — \ + modified an immutable extension slot. \ + Extension changes rejected.", + phase_label, plugin_name + ); + } else if let Some(ref orig_sec) = extensions.security { + if let Some(ref new_sec) = owned.security { + if !new_sec.labels.is_superset(&orig_sec.labels) { + warn!( + "{} plugin '{}' violated monotonic tier — \ + removed a security label. \ + Extension changes rejected.", + phase_label, plugin_name + ); + } else { + extensions.merge_owned(owned); + } + } else { + extensions.merge_owned(owned); + } + } else { + extensions.merge_owned(owned); + } } } @@ -479,7 +525,7 @@ impl Executor { &self, entries: &[HookEntry], payload: &dyn PluginPayload, - _extensions: &Extensions, + extensions: &Extensions, ctx_table: &PluginContextTable, phase_label: &str, ) { @@ -498,14 +544,22 @@ impl Executor { .cloned() .map(|mut c| { c.global_state = global_state.clone(); c }) .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); - let filtered = FilteredExtensions::default(); + // 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; match result { - Ok(Ok(_)) => {} // read-only — discard result + Ok(Ok(_)) => {} // read-only — discard result and ext_clone Ok(Err(e)) => { warn!("{} plugin '{}' error (ignored): {}", phase_label, plugin_name, e); } @@ -526,7 +580,7 @@ impl Executor { &self, entries: &[HookEntry], payload: &dyn PluginPayload, - _extensions: &Extensions, + extensions: &Extensions, ctx_table: &PluginContextTable, ) -> Option { if entries.is_empty() { @@ -562,8 +616,18 @@ impl Executor { .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); let dur = timeout_dur; + // 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)); + let handle = tokio::spawn(async move { - let filtered = FilteredExtensions::default(); timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await }); @@ -662,6 +726,7 @@ impl Executor { &self, entries: &[HookEntry], payload: &dyn PluginPayload, + extensions: &Extensions, ctx_table: &PluginContextTable, ) -> Vec<(String, tokio::task::JoinHandle<()>)> { if entries.is_empty() { @@ -685,8 +750,17 @@ impl Executor { 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)); + let handle = tokio::spawn(async move { - let filtered = FilteredExtensions::default(); let result = timeout( dur, handler.invoke(&*owned_payload, &filtered, &mut ctx), @@ -735,7 +809,7 @@ impl Default for Executor { pub struct ErasedResultFields { pub continue_processing: bool, pub modified_payload: Option>, - pub modified_extensions: Option, + pub modified_extensions: Option, pub violation: Option, } @@ -817,14 +891,20 @@ mod tests { #[test] fn test_erase_result_modify_extensions() { - let mut ext = Extensions::default(); - ext.labels.insert("PII".into()); - let result: PluginResult = PluginResult::modify_extensions(ext); + 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()); - assert!(fields.modified_extensions.as_ref().unwrap().labels.contains("PII")); + let sec = fields.modified_extensions.as_ref().unwrap().security.as_ref().unwrap(); + assert!(sec.has_label("PII")); } #[test] diff --git a/crates/cpex-core/src/extensions/agent.rs b/crates/cpex-core/src/extensions/agent.rs new file mode 100644 index 00000000..f6eb9c3b --- /dev/null +++ b/crates/cpex-core/src/extensions/agent.rs @@ -0,0 +1,60 @@ +// 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/completion.rs b/crates/cpex-core/src/extensions/completion.rs new file mode 100644 index 00000000..2c3ad14d --- /dev/null +++ b/crates/cpex-core/src/extensions/completion.rs @@ -0,0 +1,71 @@ +// 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 new file mode 100644 index 00000000..68f0f3a5 --- /dev/null +++ b/crates/cpex-core/src/extensions/container.rs @@ -0,0 +1,532 @@ +// 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; +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::request::RequestExtension; +use super::security::SecurityExtension; + +// --------------------------------------------------------------------------- +// Extensions — all Arc, fully immutable, zero-copy shareable +// --------------------------------------------------------------------------- + +/// 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>, + + /// Delegation chain (frozen as Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: 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(), + delegation: self.delegation.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(), + + // 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()), + delegation: self.delegation.as_ref().map(|arc| (**arc).clone()), + custom: self.custom.as_ref().map(|arc| (**arc).clone()), + + // Write tokens — propagated from the original + 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. + 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, + _ => false, + } + } + + 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) + } + + /// Merge an OwnedExtensions back into this Extensions. + pub fn merge_owned(&mut self, owned: OwnedExtensions) { + self.http = owned.http.map(|g| Arc::new(g.into_inner())); + self.security = owned.security.map(Arc::new); + self.delegation = owned.delegation.map(Arc::new); + self.custom = owned.custom.map(Arc::new); + } +} + +// --------------------------------------------------------------------------- +// OwnedExtensions — plugin's writeable workspace +// --------------------------------------------------------------------------- + +/// 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>, + + // Mutable/monotonic/guarded — owned, modifiable + pub http: Option>, + pub security: Option, + pub delegation: Option, + pub custom: Option>, + + // Write tokens — propagated from executor + pub http_write_token: Option, + pub labels_write_token: Option, + pub delegation_write_token: Option, +} +#[cfg(test)] +mod tests { + use super::*; + 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)); + } + + #[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::DelegationExtension; + use crate::extensions::delegation::DelegationHop; + + // Build extensions with security, http, delegation, custom + 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_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() + .map(|h| h.get_header("Authorization")) + .flatten(); + 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 new file mode 100644 index 00000000..2921cdce --- /dev/null +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -0,0 +1,161 @@ +// 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 serde::{Deserialize, Serialize}; + +/// 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. + #[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, + + /// Timestamp of delegation (ISO 8601). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + + /// 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). + #[serde(default)] + pub depth: usize, + + /// 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); + self.depth = self.chain.len(); + 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::default(); + del.origin_subject_id = Some("alice".into()); + + del.append_hop(DelegationHop { + subject_id: "alice".into(), + audience: Some("service-b".into()), + scopes_granted: vec!["read".into(), "write".into()], + strategy: Some("token_exchange".into()), + ..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_delegation_serde_roundtrip() { + let mut del = DelegationExtension::default(); + del.origin_subject_id = Some("alice".into()); + del.actor_subject_id = Some("service-b".into()); + del.append_hop(DelegationHop { + subject_id: "alice".into(), + subject_type: Some("user".into()), + 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 new file mode 100644 index 00000000..18bca78b --- /dev/null +++ b/crates/cpex-core/src/extensions/filter.rs @@ -0,0 +1,549 @@ +// 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}; + +// --------------------------------------------------------------------------- +// Slot Registry — static policies per extension slot +// --------------------------------------------------------------------------- + +/// 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, + SecurityObjects, + SecurityData, +} + +/// 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, + }, + } +} + +// --------------------------------------------------------------------------- +// Capability Checking +// --------------------------------------------------------------------------- + +/// 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() +} + +// --------------------------------------------------------------------------- +// Filter Extensions +// --------------------------------------------------------------------------- + +/// 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 { + let mut filtered = Extensions::default(); + + // Unrestricted immutable — always visible + filtered.request = extensions.request.clone(); + filtered.provenance = extensions.provenance.clone(); + filtered.completion = extensions.completion.clone(); + filtered.llm = extensions.llm.clone(); + filtered.framework = extensions.framework.clone(); + filtered.mcp = extensions.mcp.clone(); + filtered.meta = extensions.meta.clone(); + filtered.custom = extensions.custom.clone(); + + // 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))); + } + + filtered +} + +/// 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(), + // Agent identity and auth method — always included (host-set, immutable) + agent: security.agent.clone(), + auth_method: security.auth_method.clone(), + // Default empty for capability-gated fields + labels: super::MonotonicSet::new(), + subject: 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 + if let Some(ref subject) = security.subject { + if has_any_subject_capability(capabilities) { + filtered.subject = Some(build_filtered_subject(subject, capabilities)); + } + } + + 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::SecurityExtension; + use crate::extensions::meta::MetaExtension; + + 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); + } +} diff --git a/crates/cpex-core/src/extensions/framework.rs b/crates/cpex-core/src/extensions/framework.rs new file mode 100644 index 00000000..b4654055 --- /dev/null +++ b/crates/cpex-core/src/extensions/framework.rs @@ -0,0 +1,38 @@ +// 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 new file mode 100644 index 00000000..f317e95f --- /dev/null +++ b/crates/cpex-core/src/extensions/guarded.rs @@ -0,0 +1,141 @@ +// 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 spec in rust-implementation-spec.md §2.3. + +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()); + + // Write — token required + 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 new file mode 100644 index 00000000..bfd52903 --- /dev/null +++ b/crates/cpex-core/src/extensions/http.rs @@ -0,0 +1,200 @@ +// 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, +} + +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 new file mode 100644 index 00000000..adc2225c --- /dev/null +++ b/crates/cpex-core/src/extensions/llm.rs @@ -0,0 +1,27 @@ +// 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 new file mode 100644 index 00000000..c5eed384 --- /dev/null +++ b/crates/cpex-core/src/extensions/mcp.rs @@ -0,0 +1,115 @@ +// 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 new file mode 100644 index 00000000..4ba55516 --- /dev/null +++ b/crates/cpex-core/src/extensions/meta.rs @@ -0,0 +1,45 @@ +// 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 new file mode 100644 index 00000000..43235833 --- /dev/null +++ b/crates/cpex-core/src/extensions/mod.rs @@ -0,0 +1,52 @@ +// 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 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 request; +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 completion::{CompletionExtension, StopReason, TokenUsage}; +pub use delegation::{DelegationExtension, DelegationHop}; +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 request::RequestExtension; +pub use security::{ + AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, + SubjectExtension, SubjectType, +}; +pub use filter::{filter_extensions, SlotName}; +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 new file mode 100644 index 00000000..65c004c2 --- /dev/null +++ b/crates/cpex-core/src/extensions/monotonic.rs @@ -0,0 +1,183 @@ +// 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 spec in rust-implementation-spec.md §2.2. + +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 new file mode 100644 index 00000000..1873f521 --- /dev/null +++ b/crates/cpex-core/src/extensions/provenance.rs @@ -0,0 +1,27 @@ +// 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/request.rs b/crates/cpex-core/src/extensions/request.rs new file mode 100644 index 00000000..435ab1fa --- /dev/null +++ b/crates/cpex-core/src/extensions/request.rs @@ -0,0 +1,35 @@ +// 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/security.rs b/crates/cpex-core/src/extensions/security.rs new file mode 100644 index 00000000..717baa72 --- /dev/null +++ b/crates/cpex-core/src/extensions/security.rs @@ -0,0 +1,337 @@ +// 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 serde::{Deserialize, Serialize}; + +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, +} + +/// This agent's own workload identity. +/// +/// Distinct from `SubjectExtension` which represents the *caller*. +/// `AgentIdentity` represents *this agent/service* — its own +/// workload identity, OAuth client_id, and trust domain. +/// +/// Populated by the host before the pipeline runs. Plugins can +/// make decisions based on both who is calling (Subject) and +/// which agent is processing (AgentIdentity). +/// +/// Maps to AuthBridge's `AgentIdentity` and the Go bindings' +/// `SecurityExtension.Agent`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentIdentity { + /// OAuth client_id of this agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + + /// Workload identity URI (SPIFFE, k8s service account, platform-specific). + /// e.g., `spiffe://example.com/ns/team1/sa/weather-tool` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_id: Option, + + /// Trust domain of the workload identity. + /// e.g., `example.com` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust_domain: Option, +} + +/// Security-related extensions. +/// +/// Carries security labels (monotonic add-only), classification, +/// authenticated caller identity (subject), this agent's own +/// workload identity (agent), object security profiles, and +/// data policies. +#[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 caller identity (who is calling). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + + /// This agent's own workload identity (who this agent is). + /// Populated by the host, not by plugins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: 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 mut sec = SecurityExtension::default(); + sec.classification = Some("confidential".into()); + 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_agent_identity() { + let agent = AgentIdentity { + client_id: Some("weather-agent".into()), + workload_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), + trust_domain: Some("example.com".into()), + }; + assert_eq!(agent.client_id.as_deref(), Some("weather-agent")); + assert_eq!( + agent.workload_id.as_deref(), + Some("spiffe://example.com/ns/team1/sa/weather-tool") + ); + assert_eq!(agent.trust_domain.as_deref(), Some("example.com")); + } + + #[test] + fn test_agent_identity_default() { + let agent = AgentIdentity::default(); + assert!(agent.client_id.is_none()); + assert!(agent.workload_id.is_none()); + assert!(agent.trust_domain.is_none()); + } + + #[test] + fn test_security_with_agent_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() + }), + agent: Some(AgentIdentity { + client_id: Some("hr-agent".into()), + workload_id: Some("spiffe://corp.com/hr-agent".into()), + trust_domain: Some("corp.com".into()), + }), + auth_method: Some("jwt".into()), + ..Default::default() + }; + + // Caller identity + assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); + // Agent identity (distinct from caller) + assert_eq!(sec.agent.as_ref().unwrap().client_id.as_deref(), Some("hr-agent")); + assert_eq!(sec.agent.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.agent = Some(AgentIdentity { + 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.agent.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 new file mode 100644 index 00000000..a22406f9 --- /dev/null +++ b/crates/cpex-core/src/extensions/tiers.rs @@ -0,0 +1,100 @@ +// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + /// Read the authenticated subject identity. + ReadSubject, + /// Read subject roles. + ReadRoles, + /// Read subject team memberships. + ReadTeams, + /// Read subject claims (e.g., JWT claims). + ReadClaims, + /// Read subject permissions. + ReadPermissions, + /// Read the agent execution context. + 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, +} + +/// 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/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index e0339b95..d60b0376 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use crate::context::PluginContext; use crate::error::PluginError; use crate::executor::erase_result; -use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::payload::{Extensions, PluginPayload}; use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; use crate::plugin::Plugin; use crate::registry::AnyHookHandler; @@ -82,7 +82,7 @@ where async fn invoke( &self, payload: &dyn PluginPayload, - extensions: &FilteredExtensions, + extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { let typed_ref: &H::Payload = payload diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index 7f4d6ce4..e7fb48f3 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -10,7 +10,7 @@ // - [`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. -// - [`FilteredExtensions`] — capability-gated extension view passed to handlers. +// - [`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. // @@ -24,6 +24,6 @@ pub mod types; // Re-export core types at the hooks level pub use adapter::TypedHandlerAdapter; -pub use payload::{Extensions, FilteredExtensions, PluginPayload}; +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 index f46d89c6..2a9b2949 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -21,98 +21,15 @@ // modification without copying the payload. use std::any::Any; -use std::collections::HashMap; use std::fmt; -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Extensions (stub — fleshed out in Phase 3 with full CMF types) -// --------------------------------------------------------------------------- - -/// Typed container for all message extensions. -/// -/// Each field corresponds to an extension with an explicit mutability -/// tier enforced by the processing pipeline. Extensions are always -/// passed separately from the payload to handlers. -/// -/// This is a Phase 1 stub with minimal fields. Phase 3 adds the -/// full CMF extension types (SecurityExtension with MonotonicSet, -/// DelegationExtension with scope-narrowing chain, HttpExtension -/// with Guarded, etc.). -/// -/// Mirrors Python's `cpex.framework.extensions.Extensions`. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Extensions { - /// Host-provided operational metadata — entity identification, - /// tags, scope, and arbitrary properties. Immutable. - #[serde(default)] - pub meta: Option, - - /// Security labels (monotonic — add-only in the full implementation). - #[serde(default)] - pub labels: std::collections::HashSet, - - /// Custom extensions (mutable — no restrictions). - #[serde(default)] - pub custom: HashMap, -} - -/// Host-provided operational metadata about the entity being processed. -/// -/// Carries entity identification (type + name) for route resolution, -/// operational tags for policy group inheritance, scope for host-defined -/// grouping, and arbitrary properties for policy conditions. -/// -/// Immutable — set by the host before invoking the hook. Plugins -/// can read but not modify. -/// -/// Mirrors Python's `cpex.framework.extensions.meta.MetaExtension`. -#[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: std::collections::HashSet, - - /// Host-defined grouping (virtual server ID, namespace, etc.). - #[serde(default)] - pub scope: Option, - - /// Arbitrary key-value metadata. - #[serde(default)] - pub properties: HashMap, -} - -/// Capability-filtered view of Extensions for a specific plugin. -/// -/// Built by the framework before dispatching to each plugin. Fields -/// the plugin hasn't declared capabilities for are `None`. Plugins -/// receive this as a separate parameter — never inside the payload. -/// -/// Phase 1 stub — Phase 3 adds per-field capability gating matching -/// the Python `filter_extensions()` implementation. -#[derive(Debug, Clone, Default)] -pub struct FilteredExtensions { - /// Meta extension (always visible — immutable, no capability needed). - pub meta: Option, - - /// Security labels (visible with `read_labels` capability). - pub labels: Option>, - - /// Custom extensions (always visible). - pub custom: Option>, -} +// 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, +}; // --------------------------------------------------------------------------- // PluginPayload Trait @@ -139,7 +56,7 @@ pub struct FilteredExtensions { /// - `'static` — payloads must be owned types (no borrowed references). /// /// Extensions are **not** part of the payload. They are passed as a -/// separate `&FilteredExtensions` parameter to handlers. +/// separate `&Extensions` parameter to handlers. /// /// # Examples /// @@ -216,3 +133,4 @@ macro_rules! impl_plugin_payload { } }; } + diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index a437c955..e07c7ab0 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -21,7 +21,7 @@ use crate::context::PluginContext; use crate::error::PluginViolation; -use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::hooks::payload::{Extensions, PluginPayload}; use crate::plugin::Plugin; // --------------------------------------------------------------------------- @@ -90,7 +90,7 @@ pub trait HookTypeDef: Send + Sync + 'static { /// fn handle( /// &self, /// payload: MessagePayload, -/// extensions: &FilteredExtensions, +/// extensions: &Extensions, /// ctx: &PluginContext, /// ) -> PluginResult { /// PluginResult::allow() @@ -115,7 +115,7 @@ pub trait HookHandler: Plugin + Send + Sync { fn handle( &self, payload: &H::Payload, - extensions: &FilteredExtensions, + extensions: &Extensions, ctx: &mut PluginContext, ) -> H::Result; } @@ -163,7 +163,7 @@ pub trait HookHandler: Plugin + Send + Sync { /// assert!(!result.continue_processing); /// assert!(result.violation.is_some()); /// ``` -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct PluginResult { /// Whether the pipeline should continue processing. /// `false` halts the pipeline (deny). Only respected for @@ -175,10 +175,10 @@ pub struct PluginResult { pub modified_payload: Option

, /// Modified extensions. `None` means no extension changes. - /// Merged back by the framework using tier validation - /// (immutable rejected, monotonic superset-checked, etc.). - /// Only accepted from Sequential and Transform mode plugins. - pub modified_extensions: Option, + /// 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, @@ -226,7 +226,8 @@ impl PluginResult

{ } /// Modify extensions only — payload unchanged. - pub fn modify_extensions(extensions: Extensions) -> Self { + /// Takes an `OwnedExtensions` from `extensions.cow_copy()`. + pub fn modify_extensions(extensions: crate::hooks::payload::OwnedExtensions) -> Self { Self { continue_processing: true, modified_payload: None, @@ -238,7 +239,8 @@ impl PluginResult

{ } /// Modify both payload and extensions. - pub fn modify(payload: P, extensions: Extensions) -> Self { + /// 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), diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index fbede921..c95aa3e7 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -19,9 +19,12 @@ // - [`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) // - [`error`] — Error types, violations, and result types +pub mod cmf; pub mod config; +pub mod extensions; pub mod context; pub mod error; pub mod executor; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 0810bbba..e72d17c7 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -627,6 +627,75 @@ impl PluginManager { .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 hook_type = HookType::new(hook_name); + let all_entries = self.registry.entries_for_hook(&hook_type); + + if all_entries.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(all_entries, &extensions, hook_name); + + 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); + self.executor + .execute(&entries, boxed, extensions, context_table) + .await + } + // ----------------------------------------------------------------------- // Route Filtering // ----------------------------------------------------------------------- @@ -859,7 +928,7 @@ mod tests { use super::*; use crate::context::PluginContext; use crate::error::PluginViolation; - use crate::hooks::payload::FilteredExtensions; + use crate::hooks::payload::Extensions; use crate::hooks::{HookHandler, PluginResult}; use crate::plugin::{OnError, PluginMode}; use async_trait::async_trait; @@ -900,7 +969,7 @@ mod tests { fn handle( &self, _payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::allow() @@ -923,7 +992,7 @@ mod tests { fn handle( &self, _payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::deny(PluginViolation::new("denied", "test denial")) @@ -938,7 +1007,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { Err(PluginError::Execution { @@ -1084,6 +1153,74 @@ mod tests { 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 mut 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 mut 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 mut 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 mut mgr = PluginManager::default(); @@ -1240,7 +1377,7 @@ mod tests { fn handle( &self, payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::modify_payload(TestPayload { @@ -1259,7 +1396,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; @@ -1308,7 +1445,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { // Small sleep to ensure both tasks are spawned before either finishes @@ -1393,7 +1530,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { tokio::time::sleep(std::time::Duration::from_millis(200)).await; @@ -1440,7 +1577,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); @@ -1459,7 +1596,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { if ctx.get_global("writer_was_here").is_some() { @@ -1511,7 +1648,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { // Increment a counter in local_state @@ -1742,11 +1879,11 @@ routes: // First invoke — populates cache let payload: Box = Box::new(TestPayload { value: "test".into() }); let ext = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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) @@ -1757,11 +1894,11 @@ routes: // Second invoke — cache hit, still size 1 let payload2: Box = Box::new(TestPayload { value: "test2".into() }); let ext2 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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; @@ -1799,11 +1936,11 @@ routes: // Invoke for get_compensation let p1: Box = Box::new(TestPayload { value: "t".into() }); let e1 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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; @@ -1811,11 +1948,11 @@ routes: // Invoke for send_email let p2: Box = Box::new(TestPayload { value: "t".into() }); let e2 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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; @@ -1850,11 +1987,11 @@ routes: // context_table = None (first invocation) let payload: Box = Box::new(TestPayload { value: "t".into() }); let ext = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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; @@ -1893,24 +2030,24 @@ routes: // Same entity, different scopes → separate cache entries let p1: Box = Box::new(TestPayload { value: "t".into() }); let e1 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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(crate::hooks::payload::MetaExtension { + 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; @@ -1951,11 +2088,11 @@ routes: // Invoke with routing — should create override instance let payload: Box = Box::new(TestPayload { value: "t".into() }); let ext = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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) @@ -2015,13 +2152,13 @@ plugin_settings: tag_set.insert(t.to_string()); } Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + 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() } } @@ -2334,4 +2471,180 @@ routes: .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, PluginError> { + 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, PluginError> { + 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 mut 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(); + + // Build extensions with a security label + 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 mut 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(); + + // Build extensions with a request extension + 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, PluginError> { + // 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 mut 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(); + + // Build extensions WITH security data + 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 + } } diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs index b9c13f11..95b05f63 100644 --- a/crates/cpex-core/src/plugin.rs +++ b/crates/cpex-core/src/plugin.rs @@ -56,7 +56,7 @@ use crate::error::PluginError; /// } /// /// impl CmfHookHandler for MyPlugin { -/// fn cmf_hook(&self, payload: MessagePayload, ext: &FilteredExtensions, ctx: &PluginContext) -> PluginResult { +/// fn cmf_hook(&self, payload: MessagePayload, ext: &Extensions, ctx: &PluginContext) -> PluginResult { /// PluginResult::allow() /// } /// } diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index fce0466c..fd3ff3c2 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -34,7 +34,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::context::PluginContext; -use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::payload::{Extensions, PluginPayload}; use crate::hooks::trait_def::HookTypeDef; use crate::hooks::HookType; use crate::plugin::{Plugin, PluginConfig, PluginMode}; @@ -173,7 +173,7 @@ pub trait AnyHookHandler: Send + Sync { async fn invoke( &self, payload: &dyn PluginPayload, - extensions: &FilteredExtensions, + extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, crate::error::PluginError>; @@ -499,7 +499,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { let result: PluginResult = PluginResult::allow(); @@ -675,7 +675,7 @@ mod tests { let payload = TestPayload { value: "test".into(), }; - let ext = FilteredExtensions::default(); + let ext = Extensions::default(); let mut ctx = PluginContext::new(); let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &mut ctx).await.unwrap(); diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs index 6992d196..6b25f15b 100644 --- a/crates/cpex-sdk/src/lib.rs +++ b/crates/cpex-sdk/src/lib.rs @@ -15,7 +15,7 @@ pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; // Hook system pub use cpex_core::hooks::{ - Extensions, FilteredExtensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, + Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, }; // Context @@ -26,3 +26,14 @@ 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::{ + // Message and payload + CmfHook, Message, MessagePayload, + // Enums + Channel, ContentType, ResourceType, Role, + // Content parts and domain objects + AudioSource, ContentPart, DocumentSource, ImageSource, PromptRequest, PromptResult, Resource, + ResourceReference, ToolCall, ToolResult, VideoSource, +}; From 514098cfc16c17b7cbd795254658c8815e5a77e0 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Wed, 6 May 2026 10:34:30 -0600 Subject: [PATCH 04/64] feat: cgo Go bindings (#45) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor * feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor * feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor * feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor * feat: added cgo and golang bindings, examples and readme. Signed-off-by: Teryl Taylor * address P0/P1/P2 review findings (except #17) Signed-off-by: Teryl Taylor * fix: address remaining P2/P3 review findings + testing gaps Signed-off-by: Teryl Taylor * docs: add CPEX Go public API spec Signed-off-by: Frederico Araujo * docs: renamed document Signed-off-by: Frederico Araujo * feat(cpex-rust): CGO review passes 1-11 + lint cleanup + Makefile targets Signed-off-by: Teryl Taylor * fix: address linting issues, updated makefile to support building examples. Signed-off-by: Teryl Taylor * docs: updated the go spec to reflect recent changes. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Teryl Taylor Co-authored-by: Frederico Araujo --- Cargo.lock | 102 + Cargo.toml | 9 +- Makefile | 249 ++ crates/cpex-core/Cargo.toml | 3 + .../examples/cmf_capabilities_demo.rs | 177 +- crates/cpex-core/examples/plugin_demo.rs | 157 +- crates/cpex-core/src/cmf/message.rs | 4 +- crates/cpex-core/src/cmf/view.rs | 104 +- crates/cpex-core/src/config.rs | 398 +- crates/cpex-core/src/context.rs | 92 +- crates/cpex-core/src/error.rs | 118 + crates/cpex-core/src/executor.rs | 480 ++- crates/cpex-core/src/extensions/container.rs | 229 +- crates/cpex-core/src/extensions/delegation.rs | 14 +- crates/cpex-core/src/extensions/filter.rs | 40 +- crates/cpex-core/src/extensions/guarded.rs | 5 +- crates/cpex-core/src/extensions/http.rs | 26 +- crates/cpex-core/src/extensions/mod.rs | 2 +- crates/cpex-core/src/extensions/monotonic.rs | 6 +- crates/cpex-core/src/extensions/security.rs | 21 +- crates/cpex-core/src/factory.rs | 10 +- crates/cpex-core/src/hooks/adapter.rs | 23 +- crates/cpex-core/src/hooks/payload.rs | 5 +- crates/cpex-core/src/lib.rs | 2 +- crates/cpex-core/src/manager.rs | 3299 ++++++++++++++--- crates/cpex-core/src/plugin.rs | 177 +- crates/cpex-core/src/registry.rs | 125 +- crates/cpex-ffi/Cargo.toml | 31 + crates/cpex-ffi/src/lib.rs | 1313 +++++++ crates/cpex-sdk/src/lib.rs | 30 +- docs/specs/cpex-go-spec.md | 1107 ++++++ examples/go-demo/.gitignore | 3 + examples/go-demo/README.md | 349 ++ examples/go-demo/cmf_plugins.yaml | 60 + examples/go-demo/ffi/Cargo.toml | 27 + examples/go-demo/ffi/src/cmf_plugins.rs | 274 ++ examples/go-demo/ffi/src/demo_plugins.rs | 275 ++ examples/go-demo/ffi/src/lib.rs | 56 + examples/go-demo/go.mod | 12 + examples/go-demo/go.sum | 12 + examples/go-demo/main.go | 242 ++ examples/go-demo/plugins.yaml | 59 + go/cpex/README.md | 367 ++ go/cpex/cmf.go | 390 ++ go/cpex/cmf_test.go | 262 ++ go/cpex/constants.go | 66 + go/cpex/errors.go | 88 + go/cpex/ffi.go | 66 + go/cpex/go.mod | 8 + go/cpex/go.sum | 4 + go/cpex/manager.go | 550 +++ go/cpex/manager_test.go | 1175 ++++++ go/cpex/types.go | 318 ++ 53 files changed, 11850 insertions(+), 1171 deletions(-) create mode 100644 crates/cpex-ffi/Cargo.toml create mode 100644 crates/cpex-ffi/src/lib.rs create mode 100644 docs/specs/cpex-go-spec.md create mode 100644 examples/go-demo/.gitignore create mode 100644 examples/go-demo/README.md create mode 100644 examples/go-demo/cmf_plugins.yaml create mode 100644 examples/go-demo/ffi/Cargo.toml create mode 100644 examples/go-demo/ffi/src/cmf_plugins.rs create mode 100644 examples/go-demo/ffi/src/demo_plugins.rs create mode 100644 examples/go-demo/ffi/src/lib.rs create mode 100644 examples/go-demo/go.mod create mode 100644 examples/go-demo/go.sum create mode 100644 examples/go-demo/main.go create mode 100644 examples/go-demo/plugins.yaml create mode 100644 go/cpex/README.md create mode 100644 go/cpex/cmf.go create mode 100644 go/cpex/cmf_test.go create mode 100644 go/cpex/constants.go create mode 100644 go/cpex/errors.go create mode 100644 go/cpex/ffi.go create mode 100644 go/cpex/go.mod create mode 100644 go/cpex/go.sum create mode 100644 go/cpex/manager.go create mode 100644 go/cpex/manager_test.go create mode 100644 go/cpex/types.go diff --git a/Cargo.lock b/Cargo.lock index 8760f602..e8149dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -25,6 +34,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -53,6 +68,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "cpex-core" version = "0.1.0" dependencies = [ + "arc-swap", "async-trait", "futures", "hashbrown 0.15.5", @@ -61,8 +77,35 @@ dependencies = [ "serde_yaml", "thiserror", "tokio", + "tokio-util", "tracing", "uuid", + "wildmatch", +] + +[[package]] +name = "cpex-demo-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-ffi", + "serde_json", + "tracing", +] + +[[package]] +name = "cpex-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tracing", ] [[package]] @@ -299,6 +342,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -377,6 +429,25 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -411,6 +482,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -548,6 +629,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.44" @@ -605,6 +700,7 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -711,6 +807,12 @@ dependencies = [ "semver", ] +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 47acca9a..62f40dac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ resolver = "2" members = [ "crates/cpex-core", "crates/cpex-sdk", + "crates/cpex-ffi", + "examples/go-demo/ffi", ] [workspace.package] @@ -20,13 +22,18 @@ authors = ["Teryl Taylor"] [workspace.dependencies] tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } serde = { version = "1", features = ["derive", "rc"] } serde_yaml = "0.9" serde_json = "1" async-trait = "0.1" thiserror = "2" tracing = "0.1" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "serde"] } paste = "1" futures = "0.3" hashbrown = "0.15" +arc-swap = "1.7" +wildmatch = "2" +rmp-serde = "1" +serde_bytes = "0.11" diff --git a/Makefile b/Makefile index b806c970..ab47d986 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,36 @@ help: @echo " sdist Build source distribution only" @echo " verify Build and verify package with twine" @echo "" + @echo "Rust (cpex-core / cpex-ffi / cpex-sdk):" + @echo " rust-build Build the Rust workspace (debug)" + @echo " rust-build-release Build the Rust workspace (release)" + @echo " rust-test Run all Rust workspace tests" + @echo " rust-test-ffi Run only the cpex-ffi crate tests" + @echo " rust-fmt Format Rust code with rustfmt" + @echo " rust-clippy Run clippy on the Rust workspace" + @echo " rust-lint Auto-fix style + clippy issues (alias for rust-lint-fix)" + @echo " rust-lint-fix Same as rust-lint — mutating fmt + clippy --fix" + @echo " rust-lint-check Read-only fmt --check + clippy (CI-safe)" + @echo " rust-clean Remove the Rust target/ directory" + @echo "" + @echo "Go (go/cpex):" + @echo " go-build Build the Go cpex package (requires libcpex_ffi)" + @echo " go-test Run Go tests" + @echo " go-test-race Run Go tests with the race detector" + @echo " go-fmt Format Go code with gofmt" + @echo " go-vet Run go vet" + @echo " go-lint Auto-fix style + lint issues (alias for go-lint-fix)" + @echo " go-lint-fix Same as go-lint — gofmt -w + vet + golangci-lint --fix" + @echo " go-lint-check Read-only gofmt -l + vet + golangci-lint (CI-safe)" + @echo "" + @echo "Examples:" + @echo " examples-build Build all Rust + Go examples (catches stale APIs)" + @echo " examples-run Run all examples end-to-end" + @echo "" + @echo "End-to-end:" + @echo " test-all Run Rust workspace tests + Go tests w/ -race" + @echo " ci Lint-check + tests + examples-build (CI gate)" + @echo "" @echo "Utilities:" @echo " clean Remove all artifacts and builds" @echo " clean-all Remove artifacts, builds, and venv" @@ -402,6 +432,225 @@ env-example: @pip install settings-doc @settings-doc generate --class cpex.framework.settings.PluginsSettings --output-format dotenv > .env.template +# ============================================================================= +# Rust workspace (cpex-core, cpex-ffi, cpex-sdk) +# ============================================================================= + +CARGO ?= cargo +GO ?= go +GO_DIR = go/cpex + +.PHONY: rust-build +rust-build: + @echo "🦀 Building Rust workspace (debug)..." + @$(CARGO) build --workspace + @echo "✅ Rust workspace built" + +.PHONY: rust-build-release +rust-build-release: + @echo "🦀 Building Rust workspace (release)..." + @$(CARGO) build --release --workspace + @echo "✅ Rust workspace built (release)" + +.PHONY: rust-test +rust-test: + @echo "🧪 Running Rust workspace tests..." + @$(CARGO) test --workspace + @echo "✅ Rust tests passed" + +.PHONY: rust-test-ffi +rust-test-ffi: + @echo "🧪 Running cpex-ffi tests..." + @$(CARGO) test -p cpex-ffi --lib + @echo "✅ cpex-ffi tests passed" + +.PHONY: rust-fmt +rust-fmt: + @echo "🦀 Formatting Rust code..." + @$(CARGO) fmt --all + @echo "✅ Rust code formatted" + +.PHONY: rust-clippy +rust-clippy: + @echo "🦀 Running clippy..." + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "✅ Clippy clean" + +# rust-lint is a developer convenience: format the code, then apply +# clippy's auto-fixes. --allow-dirty/--allow-staged let clippy run on +# in-progress edits rather than refusing on a non-clean tree. +.PHONY: rust-lint +rust-lint: rust-lint-fix + +.PHONY: rust-lint-fix +rust-lint-fix: + @echo "🦀 Formatting + auto-fixing Rust..." + @$(CARGO) fmt --all + @$(CARGO) clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings + @echo "✅ Rust lint-fix complete" + +# rust-lint-check is the CI-safe variant: no writes. Fails if formatting +# drifted (fmt --check) or clippy has any warning. +.PHONY: rust-lint-check +rust-lint-check: + @echo "🦀 Checking Rust formatting + clippy (read-only)..." + @$(CARGO) fmt --all -- --check + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "✅ Rust lint-check passed" + +.PHONY: rust-clean +rust-clean: + @echo "🧹 Removing Rust target directory..." + @$(CARGO) clean + @echo "✅ target/ removed" + +# ============================================================================= +# Go bindings (go/cpex) +# ============================================================================= +# +# go/cpex links against the cpex-ffi cdylib at target/release. Targets +# below that touch Go ensure the release build is current first — Go's +# linker errors on missing libcpex_ffi.dylib are easy to misread. + +.PHONY: go-build +go-build: rust-build-release + @echo "🐹 Building Go cpex package..." + @cd $(GO_DIR) && $(GO) build ./... + @echo "✅ Go package built" + +.PHONY: go-test +go-test: rust-build-release + @echo "🧪 Running Go tests..." + @cd $(GO_DIR) && $(GO) test -count=1 ./... + @echo "✅ Go tests passed" + +.PHONY: go-test-race +go-test-race: rust-build-release + @echo "🧪 Running Go tests with race detector..." + @cd $(GO_DIR) && $(GO) test -count=1 -race ./... + @echo "✅ Go tests passed (with -race)" + +.PHONY: go-vet +go-vet: rust-build-release + @echo "🐹 Running go vet..." + @cd $(GO_DIR) && $(GO) vet ./... + @echo "✅ go vet clean" + +# go-fmt rewrites .go files in place via gofmt. Read-only counterpart +# is `gofmt -l`, used inside go-lint-check. +.PHONY: go-fmt +go-fmt: + @echo "🐹 Formatting Go code..." + @cd $(GO_DIR) && $(GO) fmt ./... + @echo "✅ Go code formatted" + +# go-lint is a developer convenience: format, vet, then run +# golangci-lint with --fix. We require golangci-lint to be installed — +# print an install hint rather than silently skipping it (skipping +# would let style drift land unnoticed). +GOLANGCI_LINT ?= golangci-lint + +.PHONY: go-lint +go-lint: go-lint-fix + +.PHONY: go-lint-fix +go-lint-fix: rust-build-release + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + echo "❌ golangci-lint not found. Install:"; \ + echo " brew install golangci-lint"; \ + echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + } + @echo "🐹 Formatting + auto-fixing Go..." + @cd $(GO_DIR) && $(GO) fmt ./... + @cd $(GO_DIR) && $(GO) vet ./... + @cd $(GO_DIR) && $(GOLANGCI_LINT) run --fix ./... + @echo "✅ Go lint-fix complete" + +# go-lint-check is the CI-safe variant: read-only. `gofmt -l` lists +# files that would be reformatted and we fail if that list is non-empty. +.PHONY: go-lint-check +go-lint-check: rust-build-release + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + echo "❌ golangci-lint not found. Install:"; \ + echo " brew install golangci-lint"; \ + echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + } + @echo "🐹 Checking Go formatting + vet + golangci-lint (read-only)..." + @cd $(GO_DIR) && unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "❌ Files need formatting:"; echo "$$unformatted"; \ + exit 1; \ + fi + @cd $(GO_DIR) && $(GO) vet ./... + @cd $(GO_DIR) && $(GOLANGCI_LINT) run ./... + @echo "✅ Go lint-check passed" + +# ============================================================================= +# Examples +# ============================================================================= +# +# Building examples is the cheapest way to catch stale public-API usage: +# cargo test / go test only build code reachable from tests, so an +# example file using a renamed function compiles fine in isolation but +# breaks at example-build time. Wire this into CI. + +GO_EXAMPLES_DIR = examples/go-demo + +.PHONY: rust-examples-build +rust-examples-build: + @echo "🦀 Building Rust examples..." + @$(CARGO) build --examples --workspace + @echo "✅ Rust examples built" + +.PHONY: go-examples-build +go-examples-build: rust-build-release + @echo "🐹 Building Go examples..." + @cd $(GO_EXAMPLES_DIR) && $(GO) build ./... + @echo "✅ Go examples built" + +.PHONY: examples-build +examples-build: rust-examples-build go-examples-build + @echo "✅ All examples built" + +# Running examples — useful for manual smoke-testing. Output goes to +# stdout and may be noisy. Each example is self-contained: prints +# scenario output and exits 0 on success. +.PHONY: examples-run +examples-run: examples-build + @echo "🏃 Running cpex-core plugin_demo..." + @$(CARGO) run --example plugin_demo -p cpex-core --quiet >/dev/null + @echo "✅ plugin_demo OK" + @echo "🏃 Running cpex-core cmf_capabilities_demo..." + @$(CARGO) run --example cmf_capabilities_demo -p cpex-core --quiet >/dev/null + @echo "✅ cmf_capabilities_demo OK" + @echo "🏃 Running go-demo (generic payload)..." + @cd $(GO_EXAMPLES_DIR) && $(GO) run . >/dev/null + @echo "✅ go-demo OK" + @echo "🏃 Running go-demo cmf-demo..." + @cd $(GO_EXAMPLES_DIR) && $(GO) run ./cmd/cmf-demo >/dev/null + @echo "✅ cmf-demo OK" + @echo "✅ All examples ran successfully" + +# ============================================================================= +# End-to-end +# ============================================================================= + +# test-all bundles the Rust workspace tests and the Go tests under +# the race detector. Skips the Python pytest suite — use +# `make test rust-test go-test-race` if you want all three. +.PHONY: test-all +test-all: rust-test go-test-race + @echo "✅ Rust + Go test suites passed" + +# ci is the canonical CI gate: read-only lint checks, full test +# suites, and example builds. If this passes locally, the same checks +# will pass in CI. +.PHONY: ci +ci: rust-lint-check test-all examples-build + @echo "✅ CI gate passed (lint + tests + examples)" + # ============================================================================= # Development shortcuts # ============================================================================= diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 1a6d3351..2885700f 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -17,6 +17,7 @@ authors.workspace = true [dependencies] tokio = { workspace = true } +tokio-util = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } serde_json = { workspace = true } @@ -26,3 +27,5 @@ tracing = { workspace = true } uuid = { workspace = true } futures = { workspace = true } hashbrown = { workspace = true } +arc-swap = { workspace = true } +wildmatch = { workspace = true } diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs index 1257f03d..230c5a36 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -13,12 +13,10 @@ use std::sync::Arc; use async_trait::async_trait; -use cpex_core::cmf::{ContentPart, CmfHook, Message, MessagePayload, Role, ToolCall}; +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::extensions::{HttpExtension, RequestExtension, SecurityExtension}; use cpex_core::factory::{PluginFactory, PluginInstance}; use cpex_core::hooks::adapter::TypedHandlerAdapter; use cpex_core::hooks::payload::{Extensions, MetaExtension}; @@ -38,7 +36,9 @@ struct IdentityChecker { #[async_trait] impl Plugin for IdentityChecker { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } } impl HookHandler for IdentityChecker { @@ -53,33 +53,49 @@ impl HookHandler for IdentityChecker { if is_result { // POST-INVOKE: verify the tool result came from an authorized call - let tool_name = payload.message.get_tool_results() + 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); + 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] 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() + 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); + 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::>()); + 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( @@ -114,7 +130,9 @@ struct HeaderInjector { #[async_trait] impl Plugin for HeaderInjector { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } } impl HookHandler for HeaderInjector { @@ -126,7 +144,10 @@ impl HookHandler for HeaderInjector { ) -> PluginResult { // Can see HTTP (has read_headers) if let Some(ref http) = extensions.http { - println!(" [header-injector] HTTP headers visible: {:?}", http.request_headers); + println!( + " [header-injector] HTTP headers visible: {:?}", + http.request_headers + ); } // Can NOT see security subject (no read_subject) @@ -149,7 +170,12 @@ impl HookHandler for HeaderInjector { // 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"); + modified + .http + .as_mut() + .unwrap() + .write(token) + .set_header("X-Processed-By", "header-injector"); println!(" [header-injector] Injected header 'X-Processed-By'"); } @@ -169,7 +195,9 @@ struct AuditLogger { #[async_trait] impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } } impl HookHandler for AuditLogger { @@ -183,12 +211,16 @@ impl HookHandler for AuditLogger { let phase = if is_result { "POST" } else { "PRE" }; let tool_name = if is_result { - payload.message.get_tool_results() + payload + .message + .get_tool_results() .first() .map(|tr| tr.tool_name.as_str()) .unwrap_or("unknown") } else { - payload.message.get_tool_calls() + payload + .message + .get_tool_calls() .first() .map(|tc| tc.name.as_str()) .unwrap_or("unknown") @@ -208,7 +240,9 @@ impl HookHandler for AuditLogger { } if is_result { - let is_error = payload.message.get_tool_results() + let is_error = payload + .message + .get_tool_results() .first() .map(|tr| tr.is_error) .unwrap_or(false); @@ -226,13 +260,21 @@ impl HookHandler for AuditLogger { struct IdentityCheckerFactory; impl PluginFactory for IdentityCheckerFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(IdentityChecker { cfg: config.clone() }); + 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))), + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -240,26 +282,37 @@ impl PluginFactory for IdentityCheckerFactory { struct HeaderInjectorFactory; impl PluginFactory for HeaderInjectorFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(HeaderInjector { cfg: config.clone() }); + 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))), - ], + 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() }); + 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))), + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -280,7 +333,7 @@ async fn main() { .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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)); @@ -293,7 +346,9 @@ async fn main() { schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), role: Role::Assistant, content: vec![ - ContentPart::Text { text: "Looking up compensation.".into() }, + ContentPart::Text { + text: "Looking up compensation.".into(), + }, ContentPart::ToolCall { content: ToolCall { tool_call_id: "tc_001".into(), @@ -346,12 +401,14 @@ async fn main() { // 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; + 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 { @@ -366,7 +423,10 @@ async fn main() { } } } else { - println!("Pre-invoke result: DENIED — {}", pre_result.violation.as_ref().unwrap().reason); + println!( + "Pre-invoke result: DENIED — {}", + pre_result.violation.as_ref().unwrap().reason + ); bg.wait_for_background_tasks().await; println!("\n=== Demo complete ==="); return; @@ -384,16 +444,14 @@ async fn main() { 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, - }, + 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, }, }; @@ -416,18 +474,23 @@ async fn main() { }); // 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; + 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); + println!( + "Post-invoke result: DENIED — {}", + post_result.violation.as_ref().unwrap().reason + ); } post_bg.wait_for_background_tasks().await; diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index 637eab88..f0d28f6d 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -62,12 +62,14 @@ struct IdentityResolver { #[async_trait] impl Plugin for IdentityResolver { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { println!(" [identity-resolver] initialized"); Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { + async fn shutdown(&self) -> Result<(), Box> { println!(" [identity-resolver] shutdown"); Ok(()) } @@ -82,11 +84,15 @@ impl HookHandler for IdentityResolver { ) -> PluginResult { if payload.user.is_empty() { println!(" [identity-resolver] DENIED: no user identity"); - return PluginResult::deny( - PluginViolation::new("no_identity", "User identity is required"), - ); + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); } - println!(" [identity-resolver] OK: user '{}' identified", payload.user); + println!( + " [identity-resolver] OK: user '{}' identified", + payload.user + ); PluginResult::allow() } } @@ -98,8 +104,10 @@ impl HookHandler for IdentityResolver { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - println!(" [identity-resolver] post-invoke: user '{}' completed '{}'", - payload.user, payload.tool_name); + println!( + " [identity-resolver] post-invoke: user '{}' completed '{}'", + payload.user, payload.tool_name + ); PluginResult::allow() } } @@ -111,7 +119,9 @@ struct PiiGuard { #[async_trait] impl Plugin for PiiGuard { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } // initialize() and shutdown() use defaults — no setup needed } @@ -129,14 +139,20 @@ impl HookHandler for PiiGuard { .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] 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); + println!( + " [pii-guard] OK: user '{}' has PII clearance", + payload.user + ); PluginResult::allow() } } @@ -148,7 +164,9 @@ struct AuditLogger { #[async_trait] impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } // initialize() and shutdown() use defaults — no setup needed } @@ -159,8 +177,10 @@ impl HookHandler for AuditLogger { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - println!(" [audit-logger] LOG: user='{}' tool='{}' args='{}'", - payload.user, payload.tool_name, payload.arguments); + println!( + " [audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, payload.tool_name, payload.arguments + ); PluginResult::allow() } } @@ -172,8 +192,10 @@ impl HookHandler for AuditLogger { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - println!(" [audit-logger] LOG: post-invoke user='{}' tool='{}'", - payload.user, payload.tool_name); + println!( + " [audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, payload.tool_name + ); PluginResult::allow() } } @@ -184,13 +206,21 @@ impl HookHandler for AuditLogger { struct IdentityFactory; impl PluginFactory for IdentityFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(IdentityResolver { cfg: config.clone() }); + 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))), + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -198,26 +228,37 @@ impl PluginFactory for IdentityFactory { struct PiiGuardFactory; impl PluginFactory for PiiGuardFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(PiiGuard { cfg: config.clone() }); + 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))), - ], + 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() }); + 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))), + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -248,7 +289,8 @@ fn print_result(_label: &str, result: &PipelineResult) { println!(" Result: ALLOWED"); } else { let violation = result.violation.as_ref().unwrap(); - println!(" Result: DENIED by '{}' — {} [{}]", + println!( + " Result: DENIED by '{}' — {} [{}]", violation.plugin_name.as_deref().unwrap_or("unknown"), violation.reason, violation.code, @@ -272,7 +314,7 @@ async fn main() { .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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)); @@ -282,7 +324,8 @@ async fn main() { mgr.initialize().await.unwrap(); println!("\nPlugins loaded: {}", mgr.plugin_count()); - println!("Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", + println!( + "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", mgr.has_hooks_for("tool_pre_invoke"), mgr.has_hooks_for("tool_post_invoke"), ); @@ -295,9 +338,7 @@ async fn main() { arguments: "employee_id=42".into(), }; let ext = make_tool_extensions("get_compensation", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + 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; @@ -312,21 +353,13 @@ async fn main() { 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 global_state = std::collections::HashMap::new(); - global_state.insert( - "pii_clearance".into(), - serde_json::Value::Bool(true), - ); - // Pass global state via context table let mut ctx_table = cpex_core::context::PluginContextTable::new(); - // We need to seed global_state — create a dummy entry - ctx_table.insert( - "__seed__".into(), - cpex_core::context::PluginContext::with_global_state(global_state), - ); - let (result, bg) = mgr.invoke::( - payload, ext, Some(ctx_table), - ).await; + 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; @@ -338,9 +371,9 @@ async fn main() { 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; + 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; @@ -352,9 +385,7 @@ async fn main() { arguments: "".into(), }; let ext = make_tool_extensions("list_departments", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("list_departments", &result); bg.wait_for_background_tasks().await; @@ -366,9 +397,7 @@ async fn main() { arguments: "foo=bar".into(), }; let ext = make_tool_extensions("some_other_tool", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("some_other_tool (wildcard)", &result); bg.wait_for_background_tasks().await; @@ -380,9 +409,7 @@ async fn main() { arguments: "".into(), }; let ext = make_tool_extensions("list_departments", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("list_departments (no user)", &result); bg.wait_for_background_tasks().await; diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index a8e700d5..b2bad350 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -61,9 +61,7 @@ impl Message { Self { schema_version: super::constants::SCHEMA_VERSION.to_string(), role, - content: vec![ContentPart::Text { - text: text.into(), - }], + content: vec![ContentPart::Text { text: text.into() }], channel: None, } } diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs index 3407b472..2c92e76d 100644 --- a/crates/cpex-core/src/cmf/view.rs +++ b/crates/cpex-core/src/cmf/view.rs @@ -82,15 +82,17 @@ impl ViewKind { 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, - } - } + 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, + }, } } @@ -210,12 +212,12 @@ impl<'a> MessageView<'a> { /// Whether this is a pre-execution hook (tool_pre_invoke, prompt_pre_fetch, etc.). pub fn is_pre(&self) -> bool { - self.hook.map_or(false, |h| h.contains("pre")) + self.hook.is_some_and(|h| h.contains("pre")) } /// Whether this is a post-execution hook. pub fn is_post(&self) -> bool { - self.hook.map_or(false, |h| h.contains("post")) + self.hook.is_some_and(|h| h.contains("post")) } // -- Universal properties -- @@ -225,7 +227,7 @@ impl<'a> MessageView<'a> { match self.part { ContentPart::Text { text } | ContentPart::Thinking { text } => Some(text), ContentPart::ToolResult { content: tr } => { - tr.content.as_str().map(|s| Some(s)).unwrap_or(None) + 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(), @@ -249,14 +251,10 @@ impl<'a> MessageView<'a> { /// URI for the entity. pub fn uri(&self) -> Option { match self.part { - ContentPart::ToolCall { content: tc } => { - Some(format!("tool://_/{}", tc.name)) - } + 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)) - } + ContentPart::PromptRequest { content: pr } => Some(format!("prompt://_/{}", pr.name)), _ => None, } } @@ -303,11 +301,21 @@ impl<'a> MessageView<'a> { // -- 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() } + 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 -- @@ -341,11 +349,7 @@ impl<'a> MessageView<'a> { /// 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 { + pub fn to_dict(&self, include_content: bool, include_context: bool) -> serde_json::Value { use super::constants::*; let mut result = serde_json::Map::new(); @@ -417,7 +421,8 @@ impl<'a> MessageView<'a> { 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)); + ext_map + .insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); } } @@ -540,9 +545,10 @@ pub fn iter_views<'a>( hook: Option<&'a str>, extensions: Option<&'a Extensions>, ) -> impl Iterator> { - message.content.iter().map(move |part| { - MessageView::new(part, message.role, hook, extensions) - }) + message + .content + .iter() + .map(move |part| MessageView::new(part, message.role, hook, extensions)) } // Also add iter_views to Message @@ -571,8 +577,12 @@ mod tests { 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::Thinking { + text: "Let me think...".into(), + }, + ContentPart::Text { + text: "Here's the answer.".into(), + }, ContentPart::ToolCall { content: ToolCall { tool_call_id: "tc_001".into(), @@ -658,8 +668,8 @@ mod tests { 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 + assert_eq!(views[2].action(), ViewAction::Execute); // tool call + assert_eq!(views[3].action(), ViewAction::Read); // resource } #[test] @@ -685,9 +695,9 @@ mod tests { 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[0].is_text()); // thinking + assert!(views[1].is_text()); // text + assert!(views[2].is_tool()); // tool call assert!(views[3].is_resource()); // resource } @@ -700,8 +710,8 @@ mod tests { #[test] fn test_view_with_extensions() { + use crate::extensions::{HttpExtension, SecurityExtension}; use std::sync::Arc; - use crate::extensions::{SecurityExtension, HttpExtension}; let mut security = SecurityExtension::default(); security.add_label("PII"); @@ -766,10 +776,10 @@ mod tests { #[test] fn test_to_dict_with_extensions() { - use std::sync::Arc; use crate::extensions::{ - SecurityExtension, HttpExtension, RequestExtension, AgentExtension, + AgentExtension, HttpExtension, RequestExtension, SecurityExtension, }; + use std::sync::Arc; let mut security = SecurityExtension::default(); security.add_label("PII"); @@ -813,10 +823,16 @@ mod tests { // Subject visible assert_eq!(extensions["subject"]["id"], "alice"); - assert!(extensions["subject"]["roles"].as_array().unwrap().contains(&serde_json::json!("admin"))); + 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"))); + assert!(extensions["labels"] + .as_array() + .unwrap() + .contains(&serde_json::json!("PII"))); // Environment visible assert_eq!(extensions["environment"], "production"); diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 375094e5..89d962a2 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::error::PluginError; use crate::plugin::PluginConfig; @@ -100,6 +100,18 @@ pub struct PluginSettings { /// 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 { @@ -110,10 +122,15 @@ impl Default for PluginSettings { 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 } @@ -163,7 +180,7 @@ pub struct PolicyGroup { /// Plugin references to activate when this group matches. #[serde(default)] - pub plugins: Vec, + pub plugins: Vec, } // --------------------------------------------------------------------------- @@ -181,14 +198,14 @@ pub struct PolicyGroup { /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] -pub enum PluginRef { +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 PluginRef { +impl PluginRouteRef { /// Extract the plugin name from this reference. pub fn name(&self) -> &str { match self { @@ -244,7 +261,7 @@ pub struct RouteEntry { /// Plugin references to activate for this route. #[serde(default)] - pub plugins: Vec, + pub plugins: Vec, } // --------------------------------------------------------------------------- @@ -272,19 +289,82 @@ pub struct RouteMeta { // String or List (for tool matching) // --------------------------------------------------------------------------- +/// 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). - Single(String), + /// 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(String::new()) + Self::Single(Pattern::default()) } } @@ -292,16 +372,7 @@ impl StringOrList { /// Check if this matcher matches the given name. pub fn matches(&self, name: &str) -> bool { match self { - Self::Single(pattern) => { - if pattern == "*" { - true - } else if pattern.contains('*') { - let prefix = pattern.trim_end_matches('*'); - name.starts_with(prefix) - } else { - name == pattern - } - } + Self::Single(pattern) => pattern.matches(name), Self::List(names) => names.iter().any(|n| n == name), } } @@ -312,7 +383,7 @@ impl StringOrList { // --------------------------------------------------------------------------- /// Load and parse a CPEX config from a YAML file. -pub fn load_config(path: &Path) -> Result { +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), })?; @@ -320,11 +391,10 @@ pub fn load_config(path: &Path) -> Result { } /// Parse a CPEX config from a YAML string. -pub fn parse_config(yaml: &str) -> Result { - let config: CpexConfig = - serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { - message: format!("failed to parse config YAML: {}", e), - })?; +pub fn parse_config(yaml: &str) -> Result> { + let config: CpexConfig = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { + message: format!("failed to parse config YAML: {}", e), + })?; validate_config(&config)?; Ok(config) } @@ -334,19 +404,18 @@ pub fn parse_config(yaml: &str) -> Result { // --------------------------------------------------------------------------- /// Validate a parsed config for structural correctness. -fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { +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(PluginError::Config { + 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(); + 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 = [ @@ -360,24 +429,31 @@ fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { .count(); if count == 0 { - return Err(PluginError::Config { + 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(PluginError::Config { - message: format!("route {} has multiple entity matchers (need exactly one)", i), - }); + 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(PluginError::Config { - message: format!("route {} references unknown plugin '{}'", i, plugin_ref.name()), - }); + return Err(Box::new(PluginError::Config { + message: format!( + "route {} references unknown plugin '{}'", + i, + plugin_ref.name() + ), + })); } } } @@ -385,13 +461,13 @@ fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { for (group_name, group) in &config.global.policies { for plugin_ref in &group.plugins { if !plugin_names.contains(plugin_ref.name()) { - return Err(PluginError::Config { + return Err(Box::new(PluginError::Config { message: format!( "policy group '{}' references unknown plugin '{}'", group_name, plugin_ref.name() ), - }); + })); } } } @@ -411,6 +487,24 @@ 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) +} + /// Resolve which plugins should fire for a given entity. /// /// When routing is disabled, returns all plugin names. When enabled, @@ -502,7 +596,7 @@ pub struct ResolvedPlugin { /// Collect plugin refs into the resolved list. fn collect_plugin_refs( - refs: &[PluginRef], + refs: &[PluginRouteRef], resolved: &mut Vec, route_when: Option<&str>, ) { @@ -531,79 +625,31 @@ fn find_matching_route<'a>( // Check scope compatibility 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 + (None, _) => 0, // route is global + (Some(rs), Some(rq)) if rs == rq => 100, // scopes match + (Some(_), _) => continue, // scope mismatch — skip }; - let base_specificity = match entity_type { - "tool" => { - if let Some(matcher) = &route.tool { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } - "resource" => { - if let Some(matcher) = &route.resource { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } - "prompt" => { - if let Some(matcher) = &route.prompt { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } - "llm" => { - if let Some(matcher) = &route.llm { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } + 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 when_bonus = if route.when.is_some() { + SPECIFICITY_WHEN_ONLY + } else { + 0 + }; let total = base_specificity + scope_bonus + when_bonus; - if best.map_or(true, |(s, _)| total > s) { + if best.is_none_or(|(s, _)| total > s) { best = Some((total, route)); } } @@ -803,7 +849,8 @@ routes: tags: [pii] "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + 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")); @@ -827,7 +874,8 @@ routes: - tool: get_compensation "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags()); + 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"]); } @@ -853,7 +901,8 @@ routes: - specific "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags()); + 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")); @@ -874,7 +923,8 @@ routes: - rate_limiter "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + 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()); } @@ -898,7 +948,8 @@ routes: max_requests: 10 "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + 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(); @@ -926,7 +977,8 @@ routes: sensitivity: high "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + 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()); @@ -961,23 +1013,100 @@ routes: tags: [pii] "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + 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_matches() { - let matcher = StringOrList::Single("hr-*".to_string()); + 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("*".to_string()); + 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] @@ -1034,23 +1163,30 @@ routes: // With matching scope — scoped route wins (more specific) let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", Some("hr-services"), &no_tags(), + &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 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(), + &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")); @@ -1088,9 +1224,8 @@ routes: 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 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 @@ -1116,11 +1251,13 @@ routes: - conditional_plugin "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", None, &no_tags(), - ); + 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")); + assert_eq!( + resolved[0].when.as_deref(), + Some("args.include_ssn == true") + ); } #[test] @@ -1146,9 +1283,8 @@ routes: - route_plugin "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", None, &no_tags(), - ); + 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(); diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs index 59e61a8a..2176c7bc 100644 --- a/crates/cpex-core/src/context.rs +++ b/crates/cpex-core/src/context.rs @@ -23,6 +23,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use uuid::Uuid; // --------------------------------------------------------------------------- // Plugin Context @@ -107,13 +108,88 @@ impl Default for PluginContext { // Plugin Context Table // --------------------------------------------------------------------------- -/// Lookup table of `PluginContext` instances indexed by plugin ID. +/// Threaded execution state carried from one hook invocation to the next +/// within a single request lifecycle (e.g., `pre_invoke` → `post_invoke`). /// -/// Threaded across hook invocations so that a plugin's `local_state` -/// persists from one hook to the next within the same request lifecycle -/// (e.g., `pre_invoke` → `post_invoke`). +/// The table holds the canonical pipeline state in two parts: /// -/// The caller receives the table back in `PipelineResult` and passes -/// it into the next hook invocation. On the first hook call, pass -/// `None` — the executor creates fresh contexts for each plugin. -pub type PluginContextTable = HashMap; +/// - `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/error.rs b/crates/cpex-core/src/error.rs index fd253429..576dc5bc 100644 --- a/crates/cpex-core/src/error.rs +++ b/crates/cpex-core/src/error.rs @@ -75,6 +75,124 @@ pub enum PluginError { 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) + } +} + +// --------------------------------------------------------------------------- +// Plugin Error Record +// --------------------------------------------------------------------------- + +/// 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, + }, + } + } +} + // --------------------------------------------------------------------------- // Plugin Violations // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 20a80a86..ddf4247a 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -25,7 +25,6 @@ // cpex/framework/manager.py. use std::any::Any; -use std::collections::HashMap; use std::fmt; use std::sync::Arc; use std::time::Duration; @@ -33,7 +32,8 @@ use std::time::Duration; use tokio::time::timeout; use tracing::{error, warn}; -use crate::context::{PluginContext, PluginContextTable}; +use crate::context::PluginContextTable; +use crate::error::PluginError; use crate::extensions::filter_extensions; use crate::hooks::payload::{Extensions, PluginPayload, WriteToken}; use crate::plugin::OnError; @@ -95,6 +95,15 @@ pub struct PipelineResult { /// 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, @@ -115,6 +124,7 @@ impl PipelineResult { modified_payload: Some(payload), modified_extensions: Some(extensions), violation: None, + errors: Vec::new(), metadata: None, context_table, } @@ -131,11 +141,20 @@ impl PipelineResult { modified_payload: None, 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 @@ -228,6 +247,7 @@ impl fmt::Debug for BackgroundTasks { /// /// 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, } @@ -262,6 +282,7 @@ impl Executor { payload: Box, extensions: Extensions, context_table: Option, + task_tracker: &tokio_util::task::TaskTracker, ) -> (PipelineResult, BackgroundTasks) { let mut ctx_table = context_table.unwrap_or_default(); @@ -273,11 +294,16 @@ impl Executor { } // Group entries by mode (from trusted_config) - let (sequential, transform, audit, concurrent, fire_and_forget) = - group_by_mode(entries); + 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(); // Phase 1: SEQUENTIAL — serial, chained, can block + modify if let Some(v) = self @@ -286,14 +312,15 @@ impl Executor { &mut current_payload, &mut current_extensions, &mut ctx_table, - true, // can_block - true, // can_modify + true, // can_block + true, // can_modify "SEQUENTIAL", + &mut errors, ) .await { return ( - PipelineResult::denied(v, current_extensions, ctx_table), + PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors), BackgroundTasks::empty(), ); } @@ -308,34 +335,53 @@ impl Executor { false, // can_block true, // can_modify "TRANSFORM", + &mut errors, ) .await; // Phase 3: AUDIT — serial, read-only, discard results - self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, &ctx_table, "AUDIT") - .await; + self.run_ref_phase( + &audit, + &*current_payload, + ¤t_extensions, + &ctx_table, + "AUDIT", + &mut errors, + ) + .await; // Phase 4: CONCURRENT — parallel, can block, cannot modify if let Some(violation) = self - .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) + .run_concurrent_phase( + &concurrent, + &*current_payload, + ¤t_extensions, + &ctx_table, + &mut errors, + ) .await { return ( - PipelineResult::denied(violation, current_extensions, ctx_table), + PipelineResult::denied(violation, current_extensions, ctx_table) + .with_errors(errors), BackgroundTasks::empty(), ); } - // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results + // 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), + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + .with_errors(errors), BackgroundTasks::from_handles(bg_handles), ) } @@ -354,6 +400,7 @@ impl Executor { /// 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], @@ -363,25 +410,22 @@ impl Executor { can_block: bool, can_modify: bool, phase_label: &str, + errors: &mut Vec, ) -> Option { - // Extract current global state from the table (use last plugin's - // global_state, or start empty). We maintain a running copy that - // gets set on each plugin's context and merged back after. - let mut global_state = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); - for entry in entries { - let plugin_name = entry.plugin_ref.name().to_string(); - let plugin_id = entry.plugin_ref.id().to_string(); + // 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; - // Look up existing context (preserves local_state from prior hooks) - // or create a fresh one. Set global_state to the current running copy. - let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); - ctx.global_state = global_state.clone(); + // 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. @@ -408,8 +452,11 @@ impl Executor { // 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; + let result = timeout( + timeout_dur, + entry.handler.invoke(&**payload, &filtered, &mut ctx), + ) + .await; match result { Ok(Ok(result_box)) => { @@ -417,7 +464,7 @@ impl Executor { // Check deny if !erased.continue_processing && can_block { if let Some(mut v) = erased.violation { - v.plugin_name = Some(plugin_name.clone()); + v.plugin_name = Some(plugin_name.to_string()); return Some(v); } } @@ -429,15 +476,22 @@ impl Executor { } if let Some(owned) = erased.modified_extensions { // Validate tier constraints before accepting - if !extensions.validate_immutable(&owned) { + let valid = extensions.validate_immutable(&owned); + if !valid { warn!( "{} plugin '{}' violated immutable tier — \ modified an immutable extension slot. \ Extension changes rejected.", phase_label, plugin_name ); - } else if let Some(ref orig_sec) = extensions.security { - if let Some(ref new_sec) = owned.security { + } else if capabilities.contains("read_labels") { + // Only enforce monotonic labels if the plugin + // could see them. A plugin without read_labels + // has empty labels in its filtered view — that's + // not a removal. + if let (Some(ref orig_sec), Some(ref new_sec)) = + (&extensions.security, &owned.security) + { if !new_sec.labels.is_superset(&orig_sec.labels) { warn!( "{} plugin '{}' violated monotonic tier — \ @@ -457,60 +511,89 @@ impl Executor { } } - // Merge global state changes back from the handler. - // The handler received &mut PluginContext and may have - // written to ctx.global_state directly. - if ctx.global_state != global_state { - global_state = ctx.global_state.clone(); - } + // 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 => { + 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); + v.plugin_name = Some(plugin_name.to_string()); return Some(v); } - OnError::Ignore => {} + // 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); + 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 => { + 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); + v.plugin_name = Some(plugin_name.to_string()); return Some(v); } - OnError::Ignore => {} + 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 error", phase_label, plugin_name); + warn!( + "{} plugin '{}' disabled after timeout", + phase_label, plugin_name + ); + errors.push((&timeout_err).into()); entry.plugin_ref.disable(); } } } } - // Store context back into the table (preserves local_state - // for the next hook invocation via the returned context_table). - // Note: global_state merging from plugin writes is deferred — - // handlers currently receive &PluginContext (shared ref) so - // they can't mutate global_state directly. When we add write-back - // (via PluginResult or interior mutability), merge here. - ctx_table.insert(plugin_id, ctx); + // 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 @@ -528,22 +611,15 @@ impl Executor { extensions: &Extensions, ctx_table: &PluginContextTable, phase_label: &str, + errors: &mut Vec, ) { - // Read-only phases get a snapshot of global state but don't merge back. - let global_state: HashMap = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); - for entry in entries { let plugin_name = entry.plugin_ref.name().to_string(); let plugin_id = entry.plugin_ref.id(); - let mut ctx = ctx_table - .get(plugin_id) - .cloned() - .map(|mut c| { c.global_state = global_state.clone(); c }) - .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + 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 @@ -555,16 +631,52 @@ impl Executor { 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; + 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); + 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); + 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(); + } } } } @@ -576,12 +688,20 @@ impl Executor { /// Run the concurrent phase — plugins execute truly in parallel. /// Returns the first violation if any plugin denies. + /// + /// Uses a `JoinSet` rather than `Vec + join_all` so we can: + /// - react to results as they complete (`join_next_with_id`) rather than + /// waiting for the slowest task before noticing a deny; + /// - cancel remaining tasks when a halt condition is hit (`abort_all`), + /// making `short_circuit_on_deny` actually short-circuit and bounding + /// the side-effects timed-out / errored handlers can produce. async fn run_concurrent_phase( &self, entries: &[HookEntry], payload: &dyn PluginPayload, extensions: &Extensions, ctx_table: &PluginContextTable, + errors: &mut Vec, ) -> Option { if entries.is_empty() { return None; @@ -589,31 +709,27 @@ impl Executor { // 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 shared_payload: Arc> = Arc::new(payload.clone_boxed()); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - // Snapshot global state for all concurrent plugins - let global_state: HashMap = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); - - // Spawn all handlers concurrently — each task returns just - // the invoke result. We zip outcomes back with entries to - // access PluginRef for disable() without cloning it into the spawn. - let mut handles = Vec::with_capacity(entries.len()); - - for entry in entries { + // Spawn into a JoinSet keyed by tokio task::Id so we can map a + // completed task (or a panicked one — JoinError carries the id) + // back to its entry without positional zip. + type ConcurrentTaskOutput = Result< + Result, Box>, + tokio::time::error::Elapsed, + >; + let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); + let mut id_to_index: std::collections::HashMap = + std::collections::HashMap::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { let handler = Arc::clone(&entry.handler); let payload_clone = Arc::clone(&shared_payload); - let plugin_id = entry.plugin_ref.id().to_string(); - let mut ctx = ctx_table - .get(&plugin_id) - .cloned() - .map(|mut c| { c.global_state = global_state.clone(); c }) - .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + 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 dur = timeout_dur; // Filter per plugin — each may have different capabilities. @@ -627,25 +743,71 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let handle = tokio::spawn(async move { + let abort_handle = set.spawn(async move { timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await }); - - handles.push(handle); + id_to_index.insert(abort_handle.id(), idx); } - // Collect results — zip with entries for PluginRef access - let outcomes = futures::future::join_all(handles).await; - let mut denials = Vec::new(); + let mut denials: Vec = Vec::new(); - for (entry, outcome) in entries.iter().zip(outcomes) { + while let Some(joined) = set.join_next_with_id().await { + // Pull the task::Id and outcome out of the success/error envelope + // so we can look up the entry by id even when the task panicked. + let (task_id, outcome) = match joined { + Ok((id, result)) => (id, Ok(result)), + Err(join_err) => { + let id = join_err.id(); + (id, Err(join_err)) + } + }; + let idx = match id_to_index.get(&task_id) { + Some(i) => *i, + None => { + // Should be impossible — we registered every spawn. + error!("CONCURRENT: untracked task id {:?}", task_id); + continue; + } + }; + let entry = &entries[idx]; let plugin_name = entry.plugin_ref.name(); let on_error = entry.plugin_ref.trusted_config().on_error; let result = match outcome { Ok(r) => r, Err(e) => { - error!("CONCURRENT task panicked: {}", e); + // Spawned task panicked. Apply the plugin's on_error + // policy just like a returned error or timeout. On + // Fail, abort the remaining tasks before halting. + error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, e); + let panic_err = crate::error::PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("task panicked: {}", e), + source: None, + code: Some("panic".into()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_panic", + format!("Plugin '{}' task panicked: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return 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(); + } + } continue; } }; @@ -662,6 +824,9 @@ impl Executor { }); violation.plugin_name = Some(plugin_name.to_string()); if self.config.short_circuit_on_deny { + // Real short-circuit: cancel the rest before + // they keep running and writing side-effects. + set.abort_all(); return Some(violation); } denials.push(violation); @@ -675,37 +840,53 @@ impl Executor { format!("Plugin '{}' failed: {}", plugin_name, e), ); v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); return 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(); } }, - Err(_) => match on_error { - OnError::Fail => { - 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::Ignore => { - warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); - } - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); - entry.plugin_ref.disable(); + Err(_) => { + 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 => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return 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(); + } } - }, + } } } - // Return first denial if any were collected (non-short-circuit mode) + // Return first denial if any were collected (non-short-circuit mode). + // Dropping `set` here also aborts any not-yet-completed tasks; with + // join_next_with_id() above we drained completions, so this is just + // belt-and-braces in case the loop exited unexpectedly. denials.into_iter().next() } @@ -728,17 +909,13 @@ impl Executor { 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 global_state: HashMap = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); let mut handles = Vec::with_capacity(entries.len()); @@ -746,7 +923,9 @@ impl Executor { let plugin_name = entry.plugin_ref.name().to_string(); let handler = Arc::clone(&entry.handler); let owned_payload = payload.clone_boxed(); - let mut ctx = PluginContext::with_global_state(global_state.clone()); + // 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(); @@ -760,20 +939,28 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let handle = tokio::spawn(async move { - let result = timeout( - dur, - handler.invoke(&*owned_payload, &filtered, &mut ctx), - ) - .await; + // 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); + warn!( + "FIRE_AND_FORGET plugin '{}' error (ignored): {}", + name_for_log, e + ); } Err(_) => { - warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", name_for_log); + warn!( + "FIRE_AND_FORGET plugin '{}' timed out (ignored)", + name_for_log + ); } } }); @@ -852,6 +1039,7 @@ mod tests { use crate::hooks::PluginResult; #[derive(Debug, Clone)] + #[allow(dead_code)] // test fixture — typed shape is the point, not field reads struct TestPayload { value: String, } @@ -869,9 +1057,8 @@ mod tests { #[test] fn test_erase_result_deny() { - let result: PluginResult = PluginResult::deny( - crate::error::PluginViolation::new("test", "denied"), - ); + 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); @@ -903,7 +1090,13 @@ mod tests { 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(); + let sec = fields + .modified_extensions + .as_ref() + .unwrap() + .security + .as_ref() + .unwrap(); assert!(sec.has_label("PII")); } @@ -912,11 +1105,8 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let result = PipelineResult::allowed_with( - payload, - Extensions::default(), - PluginContextTable::new(), - ); + 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()); @@ -925,11 +1115,8 @@ mod tests { #[test] fn test_pipeline_result_denied() { let violation = crate::error::PluginViolation::new("test", "denied"); - let result = PipelineResult::denied( - violation, - Extensions::default(), - PluginContextTable::new(), - ); + let result = + PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new()); assert!(!result.continue_processing); assert!(result.modified_payload.is_none()); assert!(result.violation.is_some()); @@ -938,11 +1125,12 @@ mod tests { #[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) + .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/container.rs b/crates/cpex-core/src/extensions/container.rs index 68f0f3a5..6409bf43 100644 --- a/crates/cpex-core/src/extensions/container.rs +++ b/crates/cpex-core/src/extensions/container.rs @@ -185,12 +185,19 @@ impl Extensions { } /// 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, - _ => false, + (_, None) => true, // plugin never saw it — not tampering + (None, Some(_)) => false, // plugin fabricated a slot } } @@ -289,8 +296,14 @@ mod tests { 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())); + 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] @@ -337,7 +350,11 @@ mod tests { // Can read without token assert_eq!( - cow.http.as_ref().unwrap().read().get_header("Authorization"), + cow.http + .as_ref() + .unwrap() + .read() + .get_header("Authorization"), Some("Bearer token") ); @@ -426,8 +443,8 @@ mod tests { #[test] fn test_cow_copy_modify_multiple_fields() { - use crate::extensions::DelegationExtension; use crate::extensions::delegation::DelegationHop; + use crate::extensions::DelegationExtension; // Build extensions with security, http, delegation, custom let mut security = SecurityExtension::default(); @@ -440,7 +457,9 @@ mod tests { 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())), + custom: Some(Arc::new( + [("existing".to_string(), serde_json::json!("value"))].into(), + )), meta: Some(Arc::new(MetaExtension { entity_type: Some("tool".into()), ..Default::default() @@ -462,8 +481,16 @@ mod tests { // 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"); + 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 { @@ -473,27 +500,36 @@ mod tests { }); // 4. Add custom data (mutable, no token needed) - cow.custom.as_mut().unwrap().insert( - "audit.timestamp".into(), - serde_json::json!("2026-04-29"), - ); + 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("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!(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.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"); + 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")); @@ -505,26 +541,169 @@ mod tests { 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() { + // A plugin without read_labels gets empty labels in its + // filtered view. After cow_copy + merge_owned, the pipeline's + // security labels must be preserved (not overwritten with empty). + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR"); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + // Simulate: plugin has no read_labels, so filtered security + // has empty labels. cow_copy of filtered would have empty labels. + let mut cow = ext.cow_copy(); + + // Plugin's owned security has the labels (from cow_copy of full ext) + // But in the real flow, it would be from the filtered ext. + // Simulate filtered: clear labels + cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::new(); + + // merge_owned replaces pipeline security with owned + let mut ext_mut = ext.clone(); + ext_mut.merge_owned(cow); + + // After merge, the security comes from the owned (which had empty labels) + // This is expected — the executor's monotonic check should prevent + // this case. merge_owned itself is just a field replacement. + let merged_sec = ext_mut.security.as_ref().unwrap(); + assert!(!merged_sec.has_label("PII")); // replaced by owned + } + + #[test] + fn test_merge_owned_none_http_preserves_pipeline() { + // If owned.http is None (plugin had no read_headers capability), + // merge_owned replaces with None. The executor should only call + // merge_owned when the plugin actually modified something. + let mut http = HttpExtension::default(); + http.set_request_header("X-Original", "value"); + + let mut ext = Extensions { + http: Some(Arc::new(http)), + ..Default::default() + }; + + let mut cow = ext.cow_copy(); + cow.http = None; // simulate filtered-out HTTP + + ext.merge_owned(cow); + + // HTTP is now None — this is the raw merge behavior. + // The executor guards against this by only calling merge_owned + // when the plugin returned modify_extensions. + assert!(ext.http.is_none()); + } + #[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() + 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() - .map(|h| h.get_header("Authorization")) - .flatten(); + 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()); + 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 index 2921cdce..e5f5ef50 100644 --- a/crates/cpex-core/src/extensions/delegation.rs +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -113,8 +113,10 @@ mod tests { #[test] fn test_append_multiple_hops() { - let mut del = DelegationExtension::default(); - del.origin_subject_id = Some("alice".into()); + let mut del = DelegationExtension { + origin_subject_id: Some("alice".into()), + ..Default::default() + }; del.append_hop(DelegationHop { subject_id: "alice".into(), @@ -139,9 +141,11 @@ mod tests { #[test] fn test_delegation_serde_roundtrip() { - let mut del = DelegationExtension::default(); - del.origin_subject_id = Some("alice".into()); - del.actor_subject_id = Some("service-b".into()); + 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("user".into()), diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 18bca78b..1841164a 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -238,21 +238,20 @@ fn cap_str(cap: Capability) -> String { /// 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 { - let mut filtered = Extensions::default(); - - // Unrestricted immutable — always visible - filtered.request = extensions.request.clone(); - filtered.provenance = extensions.provenance.clone(); - filtered.completion = extensions.completion.clone(); - filtered.llm = extensions.llm.clone(); - filtered.framework = extensions.framework.clone(); - filtered.mcp = extensions.mcp.clone(); - filtered.meta = extensions.meta.clone(); - filtered.custom = extensions.custom.clone(); +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(), + ..Default::default() + }; // Capability-gated: delegation if extensions.delegation.is_some() { @@ -362,8 +361,8 @@ fn build_filtered_subject( #[cfg(test)] mod tests { use super::*; - use crate::extensions::SecurityExtension; use crate::extensions::meta::MetaExtension; + use crate::extensions::SecurityExtension; fn make_full_extensions() -> Extensions { let mut security = SecurityExtension::default(); @@ -401,7 +400,9 @@ mod tests { entity_name: Some("get_compensation".into()), ..Default::default() })), - custom: Some(Arc::new([("key".to_string(), serde_json::json!("value"))].into())), + custom: Some(Arc::new( + [("key".to_string(), serde_json::json!("value"))].into(), + )), ..Default::default() } } @@ -451,10 +452,7 @@ mod tests { let filtered = filter_extensions(&ext, &caps); assert!(filtered.agent.is_some()); - assert_eq!( - filtered.agent.unwrap().agent_id, - Some("agent-1".into()) - ); + assert_eq!(filtered.agent.unwrap().agent_id, Some("agent-1".into())); assert!(filtered.http.is_none()); } diff --git a/crates/cpex-core/src/extensions/guarded.rs b/crates/cpex-core/src/extensions/guarded.rs index f317e95f..fb369a16 100644 --- a/crates/cpex-core/src/extensions/guarded.rs +++ b/crates/cpex-core/src/extensions/guarded.rs @@ -135,7 +135,10 @@ mod tests { assert!(guarded.read().map.is_empty()); // Write — token required - guarded.write(&token).map.insert("X-Auth".into(), "Bearer tok".into()); + 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 index bfd52903..3fa1157a 100644 --- a/crates/cpex-core/src/extensions/http.rs +++ b/crates/cpex-core/src/extensions/http.rs @@ -49,7 +49,11 @@ impl HttpExtension { } /// 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 { + 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; @@ -110,10 +114,7 @@ fn get_header_ci<'a>(headers: &'a HashMap, name: &str) -> Option 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(); + let key = headers.keys().find(|k| k.to_lowercase() == lower).cloned(); key.and_then(|k| headers.remove(&k)) } @@ -125,7 +126,10 @@ mod tests { 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")); + assert_eq!( + http.get_request_header("Content-Type"), + Some("application/json") + ); } #[test] @@ -194,7 +198,13 @@ mod tests { 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")); + 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/mod.rs b/crates/cpex-core/src/extensions/mod.rs index 43235833..d51aec62 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -35,6 +35,7 @@ pub use container::{Extensions, OwnedExtensions}; pub use agent::{AgentExtension, ConversationContext}; pub use completion::{CompletionExtension, StopReason, TokenUsage}; pub use delegation::{DelegationExtension, DelegationHop}; +pub use filter::{filter_extensions, SlotName}; pub use framework::FrameworkExtension; pub use guarded::{Guarded, WriteToken}; pub use http::HttpExtension; @@ -48,5 +49,4 @@ pub use security::{ AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, SubjectExtension, SubjectType, }; -pub use filter::{filter_extensions, SlotName}; 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 index 65c004c2..82530199 100644 --- a/crates/cpex-core/src/extensions/monotonic.rs +++ b/crates/cpex-core/src/extensions/monotonic.rs @@ -80,11 +80,7 @@ impl MonotonicSet { /// 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 { + pub fn remove_with_declassifier(&mut self, value: &T, _token: &DeclassifierToken) -> bool { self.inner.remove(value) } } diff --git a/crates/cpex-core/src/extensions/security.rs b/crates/cpex-core/src/extensions/security.rs index 717baa72..91d54c18 100644 --- a/crates/cpex-core/src/extensions/security.rs +++ b/crates/cpex-core/src/extensions/security.rs @@ -203,8 +203,10 @@ mod tests { #[test] fn test_security_classification() { - let mut sec = SecurityExtension::default(); - sec.classification = Some("confidential".into()); + let sec = SecurityExtension { + classification: Some("confidential".into()), + ..Default::default() + }; assert_eq!(sec.classification.as_deref(), Some("confidential")); } @@ -275,8 +277,14 @@ mod tests { // Caller identity assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); // Agent identity (distinct from caller) - assert_eq!(sec.agent.as_ref().unwrap().client_id.as_deref(), Some("hr-agent")); - assert_eq!(sec.agent.as_ref().unwrap().trust_domain.as_deref(), Some("corp.com")); + assert_eq!( + sec.agent.as_ref().unwrap().client_id.as_deref(), + Some("hr-agent") + ); + assert_eq!( + sec.agent.as_ref().unwrap().trust_domain.as_deref(), + Some("corp.com") + ); // Auth method assert_eq!(sec.auth_method.as_deref(), Some("jwt")); // Labels @@ -332,6 +340,9 @@ mod tests { }; assert_eq!(policy.apply_labels[0], "PII"); assert!(policy.retention.is_some()); - assert_eq!(policy.retention.as_ref().unwrap().max_age_seconds, Some(86400)); + assert_eq!( + policy.retention.as_ref().unwrap().max_age_seconds, + Some(86400) + ); } } diff --git a/crates/cpex-core/src/factory.rs b/crates/cpex-core/src/factory.rs index e77f80ca..95297d67 100644 --- a/crates/cpex-core/src/factory.rs +++ b/crates/cpex-core/src/factory.rs @@ -45,7 +45,7 @@ use crate::registry::AnyHookHandler; /// /// impl PluginFactory for RateLimiterFactory { /// fn create(&self, config: &PluginConfig) -/// -> Result +/// -> Result> /// { /// let plugin = Arc::new(RateLimiter::from_config(config)?); /// let handler = Arc::new(TypedHandlerAdapter::::new( @@ -62,7 +62,7 @@ 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; + fn create(&self, config: &PluginConfig) -> Result>; } /// A created plugin instance — the plugin and its type-erased handlers. @@ -110,11 +110,7 @@ impl PluginFactoryRegistry { } /// Register a factory for a given `kind` name. - pub fn register( - &mut self, - kind: impl Into, - factory: Box, - ) { + pub fn register(&mut self, kind: impl Into, factory: Box) { self.factories.insert(kind.into(), factory); } diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index d60b0376..7acc7b12 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -84,17 +84,18 @@ where payload: &dyn PluginPayload, extensions: &Extensions, ctx: &mut PluginContext, - ) -> Result, PluginError> { - 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::() - ), - })?; + ) -> 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); let plugin_result: PluginResult = result.into(); diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs index 2a9b2949..d284bf4c 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -27,9 +27,7 @@ use std::fmt; // 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, -}; +pub use crate::extensions::{Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken}; // --------------------------------------------------------------------------- // PluginPayload Trait @@ -133,4 +131,3 @@ macro_rules! impl_plugin_payload { } }; } - diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index c95aa3e7..f2f8f80c 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -24,10 +24,10 @@ pub mod cmf; pub mod config; -pub mod extensions; pub mod context; pub mod error; pub mod executor; +pub mod extensions; pub mod factory; pub mod hooks; pub mod manager; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index e72d17c7..3ad5977a 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -26,6 +26,7 @@ use std::hash::{Hash, Hasher}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use hashbrown::HashMap; @@ -47,17 +48,28 @@ use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry}; // Manager Configuration // --------------------------------------------------------------------------- +/// 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, } } } @@ -132,8 +144,20 @@ impl PartialEq for RouteCacheKey { impl Eq for RouteCacheKey {} - -pub struct PluginManager { +/// 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, @@ -143,10 +167,25 @@ pub struct PluginManager { /// 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, +} + +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. - factories: PluginFactoryRegistry, + /// + /// 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. @@ -156,26 +195,169 @@ pub struct PluginManager { /// 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, + + /// 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, +} + +/// 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(()) +} - /// Whether initialize() has been called. - initialized: bool, +/// 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, + } } impl PluginManager { /// Create a new PluginManager with the given configuration. pub fn new(config: ManagerConfig) -> Self { let cache_hasher = hashbrown::DefaultHashBuilder::default(); - Self { + let snapshot = RuntimeSnapshot { registry: PluginRegistry::new(), executor: Executor::new(config.executor), cpex_config: None, - factories: PluginFactoryRegistry::new(), - route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())), + route_cache_max_entries: config.route_cache_max_entries, + }; + Self { + runtime: arc_swap::ArcSwap::from_pointee(snapshot), + factories: RwLock::new(PluginFactoryRegistry::new()), + route_cache: RwLock::new(HashMap::with_hasher(cache_hasher)), cache_hasher, - initialized: false, + route_cache_full_warned: AtomicBool::new(false), + initialized: AtomicBool::new(false), + task_tracker: tokio_util::task::TaskTracker::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)); + 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)); + Ok(result) + } + // ----------------------------------------------------------------------- // Factory Registration // ----------------------------------------------------------------------- @@ -194,11 +376,14 @@ impl PluginManager { /// manager.load_config(Path::new("plugins.yaml"))?; /// ``` pub fn register_factory( - &mut self, + &self, kind: impl Into, factory: Box, ) { - self.factories.register(kind, factory); + self.factories + .write() + .unwrap_or_else(|p| p.into_inner()) + .register(kind, factory); } // ----------------------------------------------------------------------- @@ -220,7 +405,7 @@ impl PluginManager { /// manager.load_config_file(Path::new("plugins/config.yaml"))?; /// manager.initialize().await?; /// ``` - pub fn load_config_file(&mut self, path: &Path) -> Result<(), PluginError> { + pub fn load_config_file(&self, path: &Path) -> Result<(), Box> { let cpex_config = config::load_config(path)?; self.load_config(cpex_config) } @@ -230,46 +415,30 @@ impl PluginManager { /// 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(&mut self, cpex_config: CpexConfig) -> Result<(), PluginError> { - // Update executor settings from config - self.executor = Executor::new(ExecutorConfig { - timeout_seconds: cpex_config.plugin_settings.plugin_timeout, - short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, - }); + pub fn load_config(&self, cpex_config: CpexConfig) -> Result<(), Box> { + warn_on_inactive_settings(&cpex_config); - // Instantiate and register each plugin from config - for plugin_config in &cpex_config.plugins { - let factory = self.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 - ), - } - })?; + // 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(); - let instance = factory.create(plugin_config)?; + instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, &factories)?; - self.registry - .register_multi_handler( - instance.plugin, - plugin_config.clone(), - instance.handlers, - ) - .map_err(|msg| PluginError::Config { message: msg })?; + // Drop the factories read lock before taking other locks + // (route_cache write below) to avoid lock-ordering hazards. + drop(factories); - info!( - "Registered plugin '{}' (kind: '{}') for hooks: {:?}", - plugin_config.name, plugin_config.kind, plugin_config.hooks - ); - } + self.runtime + .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); - // Clear routing cache — config changed + // Clear routing cache — config changed. self.clear_routing_cache(); - // Store config for route resolution - self.cpex_config = Some(cpex_config); - Ok(()) } @@ -282,39 +451,22 @@ impl PluginManager { pub fn from_config( cpex_config: CpexConfig, factories: &PluginFactoryRegistry, - ) -> Result { - let mut manager = Self::new(ManagerConfig::default()); - - // Instantiate and register each plugin - for plugin_config in &cpex_config.plugins { - 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 - ), - } - })?; + ) -> Result> { + warn_on_inactive_settings(&cpex_config); - let instance = factory.create(plugin_config)?; + let manager = Self::new(ManagerConfig { + executor: ExecutorConfig::default(), + route_cache_max_entries: cpex_config.plugin_settings.route_cache_max_entries, + }); - manager - .registry - .register_multi_handler( - instance.plugin, - plugin_config.clone(), - instance.handlers, - ) - .map_err(|msg| PluginError::Config { message: msg })?; - } + // Instantiate into a fresh registry, then publish atomically. + let mut new_registry = PluginRegistry::new(); + instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, factories)?; - // Update executor from config settings - manager.executor = Executor::new(ExecutorConfig { - timeout_seconds: cpex_config.plugin_settings.plugin_timeout, - short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, - }); + manager + .runtime + .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); - manager.cpex_config = Some(cpex_config); Ok(manager) } @@ -343,10 +495,10 @@ impl PluginManager { /// manager.register_handler::(plugin, config)?; /// ``` pub fn register_handler( - &mut self, + &self, plugin: Arc

, config: PluginConfig, - ) -> Result<(), PluginError> + ) -> Result<(), Box> where H: HookTypeDef, H::Result: Into>, @@ -354,9 +506,13 @@ impl PluginManager { { let handler: Arc = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - self.registry - .register::(plugin, config, handler) - .map_err(|msg| PluginError::Config { message: msg }) + 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. @@ -373,11 +529,11 @@ impl PluginManager { /// )?; /// ``` pub fn register_handler_for_names( - &mut self, + &self, plugin: Arc

, config: PluginConfig, names: &[&str], - ) -> Result<(), PluginError> + ) -> Result<(), Box> where H: HookTypeDef, H::Result: Into>, @@ -385,9 +541,13 @@ impl PluginManager { { let handler: Arc = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - self.registry - .register_for_names::(plugin, config, handler, names) - .map_err(|msg| PluginError::Config { message: msg }) + 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). @@ -396,14 +556,18 @@ impl PluginManager { /// Python/WASM bridge hosts that implement AnyHookHandler directly. /// Most callers should use `register_handler` instead. pub fn register_raw( - &mut self, + &self, plugin: Arc, config: PluginConfig, handler: Arc, - ) -> Result<(), PluginError> { - self.registry - .register::(plugin, config, handler) - .map_err(|msg| PluginError::Config { message: msg }) + ) -> 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(()) } // ----------------------------------------------------------------------- @@ -415,29 +579,33 @@ impl PluginManager { /// Calls `plugin.initialize()` on each registered plugin. Must be /// called before invoking any hooks. Idempotent — calling twice /// has no effect. - pub async fn initialize(&mut self) -> Result<(), PluginError> { - if self.initialized { + 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", - self.registry.plugin_count() + snapshot.registry.plugin_count() ); let mut initialized_plugins: Vec = Vec::new(); - for name in self.registry.plugin_names() { - if let Some(plugin_ref) = self.registry.get(name) { + 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.to_string(); + let plugin_name = name; if let Err(e) = plugin.initialize().await { error!("Failed to initialize plugin '{}': {}", plugin_name, e); // Clean up already-initialized plugins for init_name in initialized_plugins.iter().rev() { - if let Some(pr) = self.registry.get(init_name) { + 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: {}", @@ -447,21 +615,21 @@ impl PluginManager { } } - return Err(PluginError::Execution { + 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 = true; + self.initialized.store(true, Ordering::Release); info!("PluginManager initialized successfully"); Ok(()) } @@ -471,15 +639,29 @@ impl PluginManager { /// 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. - pub async fn shutdown(&mut self) { - if !self.initialized { + /// 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"); - for name in self.registry.plugin_names() { - if let Some(plugin_ref) = self.registry.get(name) { + // 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 { @@ -489,7 +671,7 @@ impl PluginManager { } } - self.initialized = false; + self.initialized.store(false, Ordering::Release); info!("PluginManager shutdown complete"); } @@ -524,8 +706,12 @@ impl PluginManager { 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 = self.registry.entries_for_hook(&hook_type); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); if all_entries.is_empty() { return ( @@ -538,7 +724,9 @@ impl PluginManager { ); } - let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); + let entries = self + .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name) + .await; if entries.is_empty() { return ( @@ -551,8 +739,15 @@ impl PluginManager { ); } - self.executor - .execute(&entries, payload, extensions, context_table) + snapshot + .executor + .execute( + &entries, + payload, + extensions, + context_table, + &self.task_tracker, + ) .await } @@ -592,38 +787,40 @@ impl PluginManager { extensions: Extensions, context_table: Option, ) -> (PipelineResult, BackgroundTasks) { + let snapshot = self.load_runtime(); let hook_type = HookType::new(H::NAME); - let all_entries = self.registry.entries_for_hook(&hook_type); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); if all_entries.is_empty() { let boxed: Box = Box::new(payload); return ( - PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } - let entries = self.filter_entries_by_route(all_entries, &extensions, H::NAME); + 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(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } let boxed: Box = Box::new(payload); - self.executor - .execute(&entries, boxed, extensions, context_table) + snapshot + .executor + .execute( + &entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) .await } @@ -661,38 +858,40 @@ impl PluginManager { extensions: Extensions, context_table: Option, ) -> (PipelineResult, BackgroundTasks) { + let snapshot = self.load_runtime(); let hook_type = HookType::new(hook_name); - let all_entries = self.registry.entries_for_hook(&hook_type); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); if all_entries.is_empty() { let boxed: Box = Box::new(payload); return ( - PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } - let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); + 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(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } let boxed: Box = Box::new(payload); - self.executor - .execute(&entries, boxed, extensions, context_table) + snapshot + .executor + .execute( + &entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) .await } @@ -710,16 +909,27 @@ impl PluginManager { /// (refcount bump, no data copy). /// /// When routing is disabled or meta is absent, returns all entries. - fn filter_entries_by_route( + async fn filter_entries_by_route( &self, + snapshot: &RuntimeSnapshot, entries: &[crate::registry::HookEntry], extensions: &Extensions, hook_name: &str, ) -> Arc> { - // If no config or routing disabled, return all - let cpex_config = match &self.cpex_config { + // 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, - _ => return Arc::new(entries.to_vec()), + _ => { + let filtered: Vec<_> = entries + .iter() + .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions)) + .cloned() + .collect(); + return Arc::new(filtered); + } }; // Extract entity info from meta extension @@ -746,7 +956,16 @@ impl PluginManager { hasher.finish() }; { - let cache = self.route_cache.read().unwrap(); + // 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 @@ -771,10 +990,15 @@ impl PluginManager { // 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(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) { + if let Some(override_entry) = + self.create_override_instance(entry, overrides).await + { filtered.push(override_entry); continue; } @@ -785,16 +1009,37 @@ impl PluginManager { let cached = Arc::new(filtered); - // Store in cache — owned key allocated only on cache miss + // 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(), }; - { - let mut cache = self.route_cache.write().unwrap(); - cache.insert(cache_key, Arc::clone(&cached)); + // 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 @@ -803,9 +1048,28 @@ impl PluginManager { /// 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. Returns - /// None if no factory is available for the plugin's kind. - fn create_override_instance( + /// 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, @@ -813,8 +1077,6 @@ impl PluginManager { let base_config = base_entry.plugin_ref.trusted_config(); let kind = &base_config.kind; - let factory = self.factories.get(kind)?; - // Merge: start with base config, overlay with overrides let mut merged_config = base_config.clone(); if let Some(override_config) = overrides.get("config") { @@ -834,52 +1096,88 @@ impl PluginManager { } } - // Create new instance with merged config + // 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(); - match factory.create(&merged_config) { - Ok(instance) => { - // Find the handler matching the current hook - let handler = instance - .handlers - .into_iter() - .find(|(name, _)| *name == target_hook) - .map(|(_, h)| h); - - if let Some(handler) = handler { - let plugin_ref = - crate::registry::PluginRef::new(instance.plugin, merged_config); - Some(crate::registry::HookEntry { - plugin_ref, - handler, - }) - } else { - warn!( - "Override instance for '{}' has no handler for hook '{}'", - base_config.name, target_hook + 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 ); - None + return None; // fall back to base instance } } - Err(e) => { - error!( - "Failed to create override instance for '{}': {}", - base_config.name, e + }; + + // 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 ); - None // fall back to base instance + 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. + /// 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(); + 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().len() + self.route_cache + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() } // ----------------------------------------------------------------------- @@ -888,32 +1186,42 @@ impl PluginManager { /// Whether any plugins are registered for the given hook name. pub fn has_hooks_for(&self, hook_name: &str) -> bool { - self.registry.has_hooks_for(&HookType::new(hook_name)) + self.load_runtime() + .registry + .has_hooks_for(&HookType::new(hook_name)) } - /// Look up a plugin by name. - pub fn get_plugin(&self, name: &str) -> Option<&PluginRef> { - self.registry.get(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.registry.plugin_count() + self.load_runtime().registry.plugin_count() } - /// All registered plugin names. - pub fn plugin_names(&self) -> Vec<&str> { - self.registry.plugin_names() + /// 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 + self.initialized.load(Ordering::Acquire) } /// Unregister a plugin by name. - pub fn unregister(&mut self, name: &str) -> Option { - self.registry.unregister(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 } } @@ -960,9 +1268,15 @@ mod tests { #[async_trait] impl Plugin for AllowPlugin { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } } impl HookHandler for AllowPlugin { @@ -983,9 +1297,15 @@ mod tests { #[async_trait] impl Plugin for DenyPlugin { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } } impl HookHandler for DenyPlugin { @@ -1009,15 +1329,15 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { - Err(PluginError::Execution { + ) -> 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 { @@ -1054,11 +1374,20 @@ mod tests { } } + 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 mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); assert!(!mgr.is_initialized()); assert_eq!(mgr.plugin_count(), 0); @@ -1079,7 +1408,6 @@ mod tests { value: "test".into(), }); - let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; @@ -1090,9 +1418,11 @@ mod tests { #[tokio::test] async fn test_invoke_by_name_allow() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); // Clean registration — no AnyHookHandler needed mgr.register_handler::(plugin, config).unwrap(); @@ -1102,7 +1432,6 @@ mod tests { value: "test".into(), }); - let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; @@ -1112,9 +1441,11 @@ mod tests { #[tokio::test] async fn test_invoke_by_name_deny() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("deny-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1123,7 +1454,6 @@ mod tests { value: "test".into(), }); - let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; @@ -1134,9 +1464,11 @@ mod tests { #[tokio::test] async fn test_invoke_typed() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1145,7 +1477,6 @@ mod tests { value: "typed".into(), }; - let (result, _) = mgr .invoke::(payload, Extensions::default(), None) .await; @@ -1157,9 +1488,11 @@ mod tests { async fn test_invoke_named() { // invoke_named::(hook_name, ...) gives compile-time payload // type checking while routing to a specific hook name. - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1180,9 +1513,11 @@ mod tests { #[tokio::test] async fn test_invoke_named_no_plugins_for_hook() { // invoke_named with a hook name that has no registered plugins - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1202,9 +1537,11 @@ mod tests { #[tokio::test] async fn test_invoke_named_deny() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("deny-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1223,108 +1560,392 @@ mod tests { #[tokio::test] async fn test_has_hooks_for() { - let mut mgr = PluginManager::default(); + 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() }); + 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_unregister() { - let mut 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(); + async fn test_conditions_filter_plugins_when_routing_disabled() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; - assert_eq!(mgr.plugin_count(), 1); - mgr.unregister("removable"); - assert_eq!(mgr.plugin_count(), 0); - assert!(!mgr.has_hooks_for("test_hook")); - } + let counts: StdArc<[AtomicUsize; 2]> = + StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]); - #[tokio::test] - async fn test_audit_plugin_cannot_block() { - let mut mgr = PluginManager::default(); - let config = make_config("audit-denier", 10, PluginMode::Audit); - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + 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" + } + } - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); + let mgr = PluginManager::default(); - let payload: Box = Box::new(TestPayload { - value: "test".into(), + // 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(); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; + // 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)" + ); - // Audit mode — deny is suppressed, pipeline continues - assert!(result.continue_processing); + // 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_on_error_disable_skips_plugin_on_subsequent_invocations() { - let mut mgr = PluginManager::default(); + async fn test_conditions_user_patterns_glob_filters() { + use std::sync::atomic::{AtomicUsize, Ordering}; - // 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(); + static FIRED: AtomicUsize = AtomicUsize::new(0); + FIRED.store(0, Ordering::SeqCst); - // 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(); + 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() + }; - // 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); + 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-*" + ); - // 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); + 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_on_error_ignore_continues_without_disabling() { - let mut mgr = PluginManager::default(); + 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(); - // 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(); + 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; + 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 @@ -1333,24 +1954,91 @@ mod tests { 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_fail_halts_pipeline() { - let mut mgr = PluginManager::default(); + 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(); - // Register an error handler with on_error: Fail (default) - let config = make_config_with_on_error( - "strict-plugin", 10, PluginMode::Sequential, OnError::Fail, + 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, ); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + } + + /// 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.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; + 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!( @@ -1368,9 +2056,15 @@ mod tests { #[async_trait] impl Plugin for TransformPlugin { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } } impl HookHandler for TransformPlugin { @@ -1398,7 +2092,7 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> 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)) @@ -1413,23 +2107,95 @@ mod tests { #[tokio::test] async fn test_transform_modifies_payload() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("transformer", 10, PluginMode::Transform); - let plugin = Arc::new(TransformPlugin { cfg: config.clone() }); + 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 payload = TestPayload { + value: "original".into(), + }; - let (result, _) = mgr.invoke::(payload, Extensions::default(), None).await; + let (result, _) = mgr + .invoke::(payload, Extensions::default(), None) + .await; assert!(result.continue_processing); let final_payload = result.modified_payload.unwrap(); - let typed = final_payload.as_any().downcast_ref::().unwrap(); + let typed = final_payload + .as_any() + .downcast_ref::() + .unwrap(); assert_eq!(typed.value, "original_transformed"); } + /// 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}; @@ -1447,7 +2213,7 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> 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); @@ -1460,7 +2226,7 @@ mod tests { } } - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent); let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); @@ -1475,95 +2241,512 @@ mod tests { 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 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()); - } - - #[tokio::test] - async fn test_timeout_fires_on_slow_handler() { - // Create a manager with a very short timeout - let config = ManagerConfig { - executor: crate::executor::ExecutorConfig { - timeout_seconds: 1, - short_circuit_on_deny: true, - }, - }; - let mut 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()); + 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_fire_and_forget_returns_before_task_completes() { - use std::sync::atomic::{AtomicBool, Ordering}; + async fn test_concurrent_short_circuit_aborts_slow_plugin() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; - static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); - TASK_COMPLETED.store(false, Ordering::SeqCst); + static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0); + SLOW_COMPLETED.store(0, Ordering::SeqCst); - struct SlowFireAndForgetHandler; + 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 SlowFireAndForgetHandler { + impl AnyHookHandler for SlowSideEffect { async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - TASK_COMPLETED.store(true, Ordering::SeqCst); + ) -> 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 mut mgr = PluginManager::default(); + 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(); + 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(); - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, bg) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + // 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(); - // Pipeline should return immediately — before the background task finishes + 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() { + // Create a manager with a very short timeout + 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"); + 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"); + 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] @@ -1579,12 +2762,14 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> 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" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } struct ReaderHandler { @@ -1597,101 +2782,660 @@ mod tests { &self, _payload: &dyn PluginPayload, _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, PluginError> { - if ctx.get_global("writer_was_here").is_some() { - self.saw_writer.store(true, std::sync::atomic::Ordering::SeqCst); - } + 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> { + // Increment a counter in local_state + 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" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } - let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let mut 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(); + let mgr = PluginManager::default(); - // 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(); + 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: "test".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + 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!( - saw_writer.load(std::sync::atomic::Ordering::SeqCst), - "reader plugin did not see writer's global_state change" + 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_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 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 LocalWriterHandler { + impl AnyHookHandler for CountHandler { async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, PluginError> { - // Increment a counter in local_state - 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)); + _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" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } - let mut mgr = PluginManager::default(); + // 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 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(); + 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, + ); + } + } - mgr.initialize().await.unwrap(); + /// `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 { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } - // 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); + let mgr = PluginManager::default(); - // Check call_count = 1 in the returned context table - let table = &result1.context_table; - let ctx = table.values().next().expect("context table should have one entry"); - assert_eq!(ctx.get_local("call_count").unwrap().as_u64().unwrap(), 1); + // 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(); - // 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); + let result = mgr.initialize().await; + assert!( + result.is_err(), + "initialize() must propagate the init failure" + ); - // call_count should now be 2 — local_state persisted across invocations - let table2 = &result2.context_table; - let ctx2 = table2.values().next().expect("context table should have one entry"); - assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); + // 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 -- @@ -1703,11 +3447,14 @@ mod tests { fn create( &self, config: &PluginConfig, - ) -> Result { - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); + ) -> 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)], @@ -1722,11 +3469,14 @@ mod tests { fn create( &self, config: &PluginConfig, - ) -> Result { - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); + ) -> 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)], @@ -1752,7 +3502,7 @@ plugin_settings: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); assert_eq!(mgr.plugin_count(), 1); @@ -1774,7 +3524,7 @@ plugins: let mut factories = PluginFactoryRegistry::new(); factories.register("test/deny", Box::new(DenyPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); let payload: Box = Box::new(TestPayload { @@ -1803,7 +3553,11 @@ plugins: let result = PluginManager::from_config(cpex_config, &factories); match result { - Err(e) => assert!(e.to_string().contains("no factory registered"), "got: {}", e), + Err(e) => assert!( + e.to_string().contains("no factory registered"), + "got: {}", + e + ), Ok(_) => panic!("expected error for unknown kind"), } } @@ -1821,36 +3575,192 @@ plugins: kind: test/allow hooks: [test_hook] mode: sequential - priority: 10 + 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 + } + + #[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)); - factories.register("test/deny", Box::new(DenyPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + 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 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); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(!result.continue_processing); // gate denied before fallback could allow + mgr.clear_routing_cache(); + assert_eq!(mgr.routing_cache_size(), 0); } - // -- Routing cache tests -- - #[tokio::test] - async fn test_routing_cache_populated_on_first_invoke() { + async fn test_unregister_invalidates_routing_cache() { let yaml = r#" plugin_settings: routing_enabled: true @@ -1863,7 +3773,6 @@ plugins: kind: test/allow hooks: [test_hook] mode: sequential - priority: 10 routes: - tool: get_compensation "#; @@ -1871,13 +3780,10 @@ routes: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + 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 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()), @@ -1886,31 +3792,52 @@ routes: })), ..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; + // 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); + } - assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry + #[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_different_entities_separate() { + 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: @@ -1921,47 +3848,68 @@ plugins: hooks: [test_hook] mode: sequential routes: - - tool: get_compensation - - tool: send_email + - 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 mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + 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()), + 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() - })), - ..Default::default() + }; + (p, e) }; - 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; + // 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_routing_cache_cleared() { + async fn test_register_handler_invalidates_routing_cache() { let yaml = r#" plugin_settings: routing_enabled: true @@ -1981,10 +3929,9 @@ routes: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + 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 { @@ -1997,7 +3944,14 @@ routes: mgr.invoke_by_name("test_hook", payload, ext, None).await; assert_eq!(mgr.routing_cache_size(), 1); - mgr.clear_routing_cache(); + // 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); } @@ -2022,7 +3976,7 @@ routes: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); // context_table = None (first invocation) @@ -2080,7 +4034,7 @@ routes: let cpex_config = crate::config::parse_config(yaml).unwrap(); // Use register_factory + load_config so manager owns factories - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.load_config(cpex_config).unwrap(); mgr.initialize().await.unwrap(); @@ -2097,9 +4051,7 @@ routes: }; // context_table = None (first invocation) - let (result, _) = mgr - .invoke_by_name("test_hook", payload, ext, None) - .await; + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; // Plugin executed (allow plugin returns allowed) assert!(result.continue_processing); @@ -2107,6 +4059,187 @@ routes: 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 { + 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#" @@ -2122,7 +4255,7 @@ plugin_settings: "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.load_config(cpex_config).unwrap(); mgr.initialize().await.unwrap(); @@ -2203,7 +4336,7 @@ routes: - rate_limiter "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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(); @@ -2215,7 +4348,12 @@ routes: // 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) + .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 @@ -2223,7 +4361,12 @@ routes: // 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) + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "send_email", None, &[]), + None, + ) .await; assert!(r2.continue_processing); // no deny plugin fires } @@ -2245,7 +4388,7 @@ plugins: priority: 20 "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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(); @@ -2256,7 +4399,12 @@ plugins: // 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) + .invoke_by_name( + "test_hook", + p, + make_meta("tool", "anything", None, &[]), + None, + ) .await; assert!(!result.continue_processing); // denier fires (all plugins active) } @@ -2286,7 +4434,7 @@ routes: - denier "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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(); @@ -2299,10 +4447,17 @@ routes: let (result, _) = mgr .invoke_by_name("test_hook", p, Extensions::default(), None) .await; - // denier has default priority 100, allower has default 100 — order depends on registration - // but at least both fire (not filtered by routing) - // We can't assert allow/deny specifically since both run — just check it executed - assert!(result.continue_processing || !result.continue_processing); // both plugins fired + // 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] @@ -2339,7 +4494,7 @@ routes: - fallback_plugin "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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(); @@ -2350,14 +4505,24 @@ routes: // 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) + .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) + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "unknown_tool", None, &[]), + None, + ) .await; assert!(r2.continue_processing); // fallback_plugin allows } @@ -2388,7 +4553,7 @@ routes: - tool: get_compensation "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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(); @@ -2399,7 +4564,12 @@ routes: // 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) + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) .await; assert!(r1.continue_processing); @@ -2409,7 +4579,12 @@ routes: // 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) + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "get_compensation", None, &["urgent"]), + None, + ) .await; assert!(!r2.continue_processing); } @@ -2443,7 +4618,7 @@ routes: - tool: send_email "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + 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(); @@ -2484,7 +4659,7 @@ routes: _payload: &dyn PluginPayload, extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { let mut ext = extensions.cow_copy(); if let Some(ref mut sec) = ext.security { sec.add_label("PLUGIN_ADDED"); @@ -2493,7 +4668,9 @@ routes: result.modified_extensions = Some(ext); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } /// Handler that tampers with an immutable extension slot. @@ -2506,30 +4683,33 @@ routes: _payload: &dyn PluginPayload, extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> 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() - } - )); + 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" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } #[tokio::test] async fn test_executor_accepts_valid_label_addition() { - let mut mgr = PluginManager::default(); + 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 plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); let handler: Arc = Arc::new(LabelAdderHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); // Build extensions with a security label @@ -2541,7 +4721,9 @@ routes: ..Default::default() }; - let payload: Box = Box::new(TestPayload { value: "test".into() }); + 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); @@ -2554,11 +4736,14 @@ routes: #[tokio::test] async fn test_executor_rejects_immutable_tampering() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("tamperer", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); let handler: Arc = Arc::new(ImmutableTampererHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); // Build extensions with a request extension @@ -2570,7 +4755,9 @@ routes: ..Default::default() }; - let payload: Box = Box::new(TestPayload { value: "test".into() }); + 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); @@ -2600,27 +4787,33 @@ routes: _payload: &dyn PluginPayload, extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { // Check if security is visible if extensions.security.is_some() { - self.saw_security.store(true, std::sync::atomic::Ordering::SeqCst); + 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" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let mut mgr = PluginManager::default(); + 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 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.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); // Build extensions WITH security data @@ -2636,7 +4829,9 @@ routes: ..Default::default() }; - let payload: Box = Box::new(TestPayload { value: "test".into() }); + 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); diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs index 95b05f63..dd743a24 100644 --- a/crates/cpex-core/src/plugin.rs +++ b/crates/cpex-core/src/plugin.rs @@ -51,8 +51,8 @@ use crate::error::PluginError; /// ```rust,ignore /// impl Plugin for MyPlugin { /// fn config(&self) -> &PluginConfig { &self.config } -/// async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } -/// async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +/// async fn initialize(&self) -> Result<(), Box> { Ok(()) } +/// async fn shutdown(&self) -> Result<(), Box> { Ok(()) } /// } /// /// impl CmfHookHandler for MyPlugin { @@ -90,7 +90,7 @@ pub trait Plugin: Send + Sync { /// Called before any hook invocations. Use this to establish /// connections, load resources, or validate configuration. /// Default implementation does nothing. - async fn initialize(&self) -> Result<(), PluginError> { + async fn initialize(&self) -> Result<(), Box> { Ok(()) } @@ -99,7 +99,7 @@ pub trait Plugin: Send + Sync { /// Called once during teardown. Use this to flush buffers, close /// connections, or release resources. /// Default implementation does nothing. - async fn shutdown(&self) -> Result<(), PluginError> { + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } } @@ -204,6 +204,86 @@ pub struct PluginConfig { 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 } @@ -274,22 +354,47 @@ pub struct PluginCondition { 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 field that is `Some(set)` matches if the given value is in the set. - /// All specified fields must match (AND semantics). - pub fn matches( - &self, - server_id: Option<&str>, - tenant_id: Option<&str>, - tool: Option<&str>, - prompt: Option<&str>, - resource: Option<&str>, - agent: Option<&str>, - ) -> bool { - let check = |field: &Option>, value: Option<&str>| -> bool { + /// 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 { @@ -299,12 +404,38 @@ impl PluginCondition { } }; - check(&self.server_ids, server_id) - && check(&self.tenant_ids, tenant_id) - && check(&self.tools, tool) - && check(&self.prompts, prompt) - && check(&self.resources, resource) - && check(&self.agents, agent) + // 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) } } @@ -335,6 +466,7 @@ impl PluginCondition { /// | 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] @@ -397,6 +529,7 @@ impl fmt::Display for PluginMode { /// 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] diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index fd3ff3c2..0b4990c1 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -31,7 +31,9 @@ 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}; @@ -68,7 +70,10 @@ pub struct PluginRef { trusted_config: PluginConfig, /// Unique identifier assigned by the registry. - id: String, + /// 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 @@ -84,11 +89,10 @@ impl PluginRef { /// 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 { - let id = uuid::Uuid::new_v4().to_string(); Self { plugin, trusted_config, - id, + id: Uuid::new_v4(), disabled: Arc::new(AtomicBool::new(false)), } } @@ -104,8 +108,9 @@ impl PluginRef { } /// Unique identifier assigned at registration. - pub fn id(&self) -> &str { - &self.id + /// Returned by value — `Uuid` is `Copy` (16 bytes). + pub fn id(&self) -> Uuid { + self.id } /// Convenience: plugin name from the trusted config. @@ -115,8 +120,12 @@ impl PluginRef { /// 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::Relaxed) { + if self.disabled.load(Ordering::Acquire) { PluginMode::Disabled } else { self.trusted_config.mode @@ -127,14 +136,20 @@ impl PluginRef { /// /// 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 instantly visible across the system. + /// `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::Relaxed); + 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::Relaxed) + self.disabled.load(Ordering::Acquire) } /// Convenience: plugin priority from the trusted config. @@ -175,7 +190,7 @@ pub trait AnyHookHandler: Send + Sync { payload: &dyn PluginPayload, extensions: &Extensions, ctx: &mut PluginContext, - ) -> Result, crate::error::PluginError>; + ) -> Result, Box>; /// The hook type name this handler was registered for. fn hook_type_name(&self) -> &'static str; @@ -189,10 +204,15 @@ pub trait AnyHookHandler: Send + Sync { /// /// 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: PluginRef, + pub plugin_ref: Arc, /// The type-erased handler for this specific hook. pub handler: Arc, @@ -216,9 +236,19 @@ pub struct HookEntry { /// - `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). - plugins: HashMap, + /// 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>, @@ -294,7 +324,6 @@ impl PluginRegistry { 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 @@ -315,12 +344,12 @@ impl PluginRegistry { return Err(format!("plugin '{}' is already registered", name)); } - let plugin_ref = PluginRef::new(plugin, config); + 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: plugin_ref.clone(), + plugin_ref: Arc::clone(&plugin_ref), handler: Arc::clone(handler), }; self.hook_index.entry(hook_type).or_default().push(entry); @@ -352,13 +381,13 @@ impl PluginRegistry { return Err(format!("plugin '{}' is already registered", name)); } - let plugin_ref = PluginRef::new(plugin, config); + 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: plugin_ref.clone(), + plugin_ref: Arc::clone(&plugin_ref), handler: Arc::clone(&handler), }; self.hook_index.entry(hook_type).or_default().push(entry); @@ -379,8 +408,8 @@ impl PluginRegistry { /// Unregister a plugin by name. /// /// Removes the PluginRef from the name index and all HookEntries - /// from the hook index. Returns the PluginRef if found. - pub fn unregister(&mut self, name: &str) -> Option { + /// 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)?; // Remove from hook index @@ -394,9 +423,11 @@ impl PluginRegistry { Some(plugin_ref) } - /// Look up a PluginRef by name. - pub fn get(&self, name: &str) -> Option<&PluginRef> { - self.plugins.get(name) + /// 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. @@ -422,9 +453,11 @@ impl PluginRegistry { self.plugins.len() } - /// All registered plugin names. - pub fn plugin_names(&self) -> Vec<&str> { - self.plugins.keys().map(|s| s.as_str()).collect() + /// 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() } } @@ -444,15 +477,15 @@ impl Default for PluginRegistry { /// Returns a tuple of five vectors in execution order: /// (sequential, transform, audit, concurrent, fire_and_forget). /// Disabled plugins are excluded. -pub fn group_by_mode( - entries: &[HookEntry], -) -> ( +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(); @@ -484,6 +517,7 @@ mod tests { // -- 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, } @@ -501,7 +535,7 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } @@ -546,10 +580,10 @@ mod tests { fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { + async fn initialize(&self) -> Result<(), Box> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } } @@ -588,7 +622,11 @@ mod tests { plugin, config, handler, - &["cmf.tool_pre_invoke", "cmf.tool_post_invoke", "cmf.llm_input"], + &[ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + ], ) .unwrap(); @@ -609,8 +647,12 @@ mod tests { 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()); + 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] @@ -623,8 +665,10 @@ mod tests { 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(); + 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 @@ -678,7 +722,10 @@ mod tests { let ext = Extensions::default(); let mut ctx = PluginContext::new(); - let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &mut ctx).await.unwrap(); + 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-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml new file mode 100644 index 00000000..73d55683 --- /dev/null +++ b/crates/cpex-ffi/Cargo.toml @@ -0,0 +1,31 @@ +# Location: ./crates/cpex-ffi/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX FFI — C API for embedding the CPEX runtime in Go, Python, etc. +# Compiles to a shared library (cdylib) exporting extern "C" functions. +# Payloads cross the FFI boundary as MessagePack bytes. + +[package] +name = "cpex-ffi" +description = "CPEX C FFI — shared library for Go/Python/WASM host bindings." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +rmp-serde = { workspace = true } +serde_bytes = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs new file mode 100644 index 00000000..f8bb615f --- /dev/null +++ b/crates/cpex-ffi/src/lib.rs @@ -0,0 +1,1313 @@ +// Location: ./crates/cpex-ffi/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX FFI — C API for embedding the CPEX runtime. +// +// Exports extern "C" functions that Go (via cgo), Python (via ctypes/cffi), +// 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. + +use std::os::raw::{c_char, c_int}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::sync::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::manager::PluginManager; + +// --------------------------------------------------------------------------- +// FFI Result Codes +// --------------------------------------------------------------------------- +// +// FFI functions return c_int. 0 means success; negative codes classify +// failures so the Go (or other-language) caller can produce a typed +// error rather than a single opaque "invoke failed" string. The codes +// 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`. + +/// Operation succeeded. +pub const RC_OK: c_int = 0; +/// Manager 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). +pub const RC_INVALID_INPUT: c_int = -2; +/// Parse / deserialize step failed (YAML config, MessagePack payload, +/// MessagePack extensions). +pub const RC_PARSE_ERROR: c_int = -3; +/// Pipeline / lifecycle step failed: `load_config` returned Err, +/// `initialize` returned Err, or a plugin signalled failure during +/// invoke without a wall-clock timeout or panic. +pub const RC_PIPELINE_ERROR: c_int = -4; +/// Result serialization (post-pipeline) failed — usually OOM on +/// `rmp_serde::to_vec_named` or unserializable JSON value. +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. +pub const RC_PANIC: c_int = -7; + +/// Outer wall-clock timeout for any FFI-driven async call. Per-plugin +/// `tokio::time::timeout` only catches cooperative-async timeouts; this +/// catches CPU-bound or thread-blocking plugins that never yield. Set +/// generously — bigger than any reasonable per-plugin timeout — so the +/// usual case never hits this bound. +const FFI_WALL_CLOCK_TIMEOUT: Duration = Duration::from_secs(60); + +// --------------------------------------------------------------------------- +// Shared Tokio Runtime +// --------------------------------------------------------------------------- +// +// One process-singleton runtime serves every manager rather than each +// `cpex_manager_new` building its own. With many managers (multi-tenant +// hosts that create one per request, dynamic plugin reload, etc.) the +// per-manager model exploded thread count: 100 managers × num_cpus +// workers each = hundreds of OS threads (and ~2MB stack apiece). +// +// Worker thread count precedence (highest first): +// 1. `cpex_configure_runtime(N)` — explicit FFI call, before first use. +// 2. `CPEX_FFI_WORKER_THREADS` env var — operator-friendly; read once +// on first use of `shared_runtime()`. +// 3. tokio default (`num_cpus`). +// +// Once the runtime is initialized it's fixed for the process lifetime. +static SHARED_RUNTIME: OnceLock = OnceLock::new(); + +/// Name of the env var operators set to bound worker threads without +/// recompiling host code or touching YAML. +const ENV_WORKER_THREADS: &str = "CPEX_FFI_WORKER_THREADS"; + +/// Parse `CPEX_FFI_WORKER_THREADS` if set. Returns `Some(n)` for valid +/// positive integers, `None` for unset / zero / negative / unparseable +/// values (in which case the runtime falls back to tokio's default). +/// Logs a warning on malformed values so operators see why their +/// setting was ignored. +/// +/// 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()?; + match raw.parse::() { + Ok(n) if n > 0 => Some(n), + Ok(_) => { + tracing::warn!( + "cpex-ffi: {}={} is not a positive integer; using num_cpus default", + ENV_WORKER_THREADS, + raw, + ); + None + } + Err(_) => { + tracing::warn!( + "cpex-ffi: {}={:?} is not parseable as a positive integer; using num_cpus default", + ENV_WORKER_THREADS, + raw, + ); + None + } + } +} + +/// Get (or lazily initialize on first call) the shared tokio runtime. +/// +/// On first call: respects `CPEX_FFI_WORKER_THREADS` if set. If the env +/// var is absent or invalid, defaults to tokio's `num_cpus`. The FFI +/// path `cpex_configure_runtime` overrides both — it `set`s the +/// OnceLock before this function is called, so by the time +/// `get_or_init` runs the runtime is already there and the env var is +/// ignored. +fn shared_runtime() -> &'static tokio::runtime::Runtime { + SHARED_RUNTIME.get_or_init(|| { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.enable_all(); + if let Some(n) = worker_threads_from_env() { + builder.worker_threads(n); + tracing::info!( + "cpex-ffi: shared runtime using {} worker threads (from {})", + n, + ENV_WORKER_THREADS, + ); + } + builder + .build() + .expect("cpex-ffi: failed to build shared tokio 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 +/// 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. +/// +/// Precedence: this FFI call beats `CPEX_FFI_WORKER_THREADS` (the env +/// var is read only on lazy init via `shared_runtime()`; an explicit +/// `set` here populates the OnceLock first and short-circuits that +/// path). Operators can set the env var as a default; host code can +/// override. +/// +/// # Safety +/// Safe to call from a single thread before any manager creation. +/// Calling after a manager exists is well-defined (returns +/// `RC_INVALID_INPUT`) but does not change the active runtime. +#[no_mangle] +pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { + if worker_threads <= 0 { + return RC_INVALID_INPUT; + } + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads as usize) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + tracing::error!("cpex_configure_runtime: build failed: {}", e); + return RC_PIPELINE_ERROR; + } + }; + match SHARED_RUNTIME.set(rt) { + Ok(()) => RC_OK, + Err(_) => { + // Runtime already initialized — caller should have set + // this before any manager creation. + tracing::warn!( + "cpex_configure_runtime: runtime already initialized; \ + configuration ignored. Call before any cpex_manager_new.", + ); + RC_INVALID_INPUT + } + } +} + +/// Outcome of `run_safely`. Lets the caller distinguish timeout vs. +/// panic so they can return the right `RC_*` code instead of collapsing +/// both into a generic failure. +enum SafeRun { + Ok(T), + Timeout, + Panicked, +} + +impl SafeRun { + /// Code to return from the FFI function on a non-Ok outcome. + /// On `Ok(_)` callers continue with the wrapped value; this is only + /// consulted on the failure paths. + fn rc(&self) -> c_int { + match self { + SafeRun::Ok(_) => RC_OK, + SafeRun::Timeout => RC_TIMEOUT, + SafeRun::Panicked => RC_PANIC, + } + } +} + +/// Run a future on the shared tokio runtime with two layers of safety: +/// +/// 1. `tokio::time::timeout` bounds the total wall-clock time. A plugin +/// that blocks an OS thread (rather than awaiting cooperatively) will +/// eventually surface as `Err(Elapsed)` here instead of hanging the +/// calling goroutine forever. +/// 2. `std::panic::catch_unwind` converts any panic that escapes the +/// pipeline into an `Err`, preventing it from unwinding across the +/// `extern "C"` boundary (which is UB on Rust < 1.81 and an abort on +/// >= 1.81). +/// +/// Returns a `SafeRun` so the caller can map the failure shape to a +/// specific `RC_*` code. +fn run_safely(fut: F, op_name: &str) -> SafeRun +where + F: std::future::Future, +{ + // `tokio::time::timeout` must be constructed inside an active runtime + // context — it registers a timer with the runtime's timer driver. + // Wrap construction in an `async` block so it happens INSIDE block_on, + // not before. (Constructing it outside panics with "there is no + // reactor running".) + let result = catch_unwind(AssertUnwindSafe(|| { + shared_runtime() + .block_on(async move { tokio::time::timeout(FFI_WALL_CLOCK_TIMEOUT, fut).await }) + })); + match result { + Ok(Ok(value)) => SafeRun::Ok(value), + Ok(Err(_elapsed)) => { + tracing::error!( + "FFI {}: wall-clock timeout exceeded ({}s) — plugin likely \ + not yielding (CPU-bound or std::thread::sleep)", + op_name, + FFI_WALL_CLOCK_TIMEOUT.as_secs(), + ); + SafeRun::Timeout + } + Err(_panic_payload) => { + tracing::error!( + "FFI {}: plugin panicked across FFI boundary — caught to \ + prevent UB; returning failure to caller", + op_name, + ); + SafeRun::Panicked + } + } +} + +// --------------------------------------------------------------------------- +// Payload Type Registry +// --------------------------------------------------------------------------- + +/// Payload type IDs — must match Go constants. +pub const PAYLOAD_GENERIC: u8 = 0; +pub const PAYLOAD_CMF_MESSAGE: u8 = 1; + +/// Deserialize a MessagePack payload based on its type ID. +/// Array-indexed — O(1) lookup, zero allocation. +fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result, String> { + match payload_type { + PAYLOAD_GENERIC => { + let value: serde_json::Value = rmp_serde::from_slice(bytes) + .map_err(|e| format!("generic payload deserialize failed: {}", e))?; + Ok(Box::new(GenericPayload { value })) + } + PAYLOAD_CMF_MESSAGE => { + let msg: cpex_core::cmf::MessagePayload = rmp_serde::from_slice(bytes) + .map_err(|e| format!("CMF payload deserialize failed: {}", e))?; + Ok(Box::new(msg)) + } + _ => Err(format!("unknown payload type: {}", payload_type)), + } +} + +/// 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. +/// +/// Errors flow up so the FFI boundary can surface them as a synthetic +/// `PluginErrorRecord` in `result.errors` rather than silently dropping +/// a plugin's modification. Two failure modes: +/// - downcast didn't match any registered type (plugin returned a +/// payload not in the FFI registry) +/// - rmp_serde encoding failed (very unlikely for our known types, +/// but bubble it up rather than swallow it) +fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), String> { + // Try CMF MessagePayload first (most common) + if let Some(mp) = payload + .as_any() + .downcast_ref::() + { + return rmp_serde::to_vec_named(mp) + .map(|b| (PAYLOAD_CMF_MESSAGE, b)) + .map_err(|e| format!("CMF payload serialize failed: {e}")); + } + // Try GenericPayload + if let Some(gp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(&gp.value) + .map(|b| (PAYLOAD_GENERIC, b)) + .map_err(|e| format!("generic payload serialize failed: {e}")); + } + Err("unknown payload type, cannot serialize across FFI".to_string()) +} + +// --------------------------------------------------------------------------- +// Opaque Handle Types +// --------------------------------------------------------------------------- + +/// Opaque handle to a PluginManager. +/// +/// All managers share the process-singleton runtime returned by +/// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. +pub struct CpexManagerInner { + pub manager: PluginManager, +} + +/// Opaque handle to a ContextTable (Rust-owned, not serialized). +pub struct CpexContextTableInner { + table: PluginContextTable, +} + +/// Opaque handle to BackgroundTasks (Rust-owned, not serialized). +pub struct CpexBackgroundTasksInner { + tasks: BackgroundTasks, +} + +// --------------------------------------------------------------------------- +// Helper: safe string from C +// --------------------------------------------------------------------------- + +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); + std::str::from_utf8(bytes).ok() +} + +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)) +} + +/// Allocate a byte buffer and return it to the caller. +/// The caller must free it with `cpex_free_bytes`. +/// +/// Returns `(null, 0)` for empty input (`std::alloc::alloc` with size=0 +/// is UB per its docs) and for buffers that wouldn't fit in `c_int` — +/// `c_int` is i32, so a buffer >= 2 GiB would silently truncate to a +/// negative length and `cpex_free_bytes` would dealloc with the wrong +/// size, corrupting the allocator. +fn alloc_bytes(data: &[u8]) -> (*mut u8, c_int) { + let len = data.len(); + if len == 0 { + return (ptr::null_mut(), 0); + } + if len > c_int::MAX as usize { + tracing::error!( + "alloc_bytes: payload size {} exceeds c_int::MAX ({}); refusing", + len, + c_int::MAX, + ); + return (ptr::null_mut(), 0); + } + let layout = std::alloc::Layout::from_size_align(len, 1).unwrap(); + unsafe { + let ptr = std::alloc::alloc(layout); + if ptr.is_null() { + return (ptr::null_mut(), 0); + } + std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, len); + (ptr, len as c_int) + } +} + +// --------------------------------------------------------------------------- +// Manager Lifecycle +// --------------------------------------------------------------------------- + +/// Create a new PluginManager from a YAML config string. +/// +/// Returns an opaque handle. The manager owns a tokio runtime for +/// async plugin execution. Returns NULL on failure. +/// +/// # 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( + config_yaml: *const c_char, + config_len: c_int, +) -> *mut CpexManagerInner { + let yaml = match 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 = PluginManager::default(); + + // Load config — factories must be registered separately via cpex_register_factory + if let Err(e) = manager.load_config(cpex_config) { + tracing::error!("cpex_manager_new: load_config failed: {}", e); + return ptr::null_mut(); + } + + Box::into_raw(Box::new(CpexManagerInner { manager })) +} + +/// Create a new PluginManager with default config (no YAML). +/// +/// Useful when registering plugins programmatically. +#[no_mangle] +pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { + let _ = shared_runtime(); + let manager = PluginManager::default(); + Box::into_raw(Box::new(CpexManagerInner { manager })) +} + +/// Load a YAML config into an existing manager. +/// +/// 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 +/// 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] +pub unsafe extern "C" fn cpex_load_config( + mgr: *const CpexManagerInner, + config_yaml: *const c_char, + config_len: c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let yaml = match c_str_to_slice(config_yaml, config_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + let cpex_config = match cpex_core::config::parse_config(yaml) { + Ok(c) => c, + Err(e) => { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } + }; + + // load_config is sync (no .await), but we still wrap in catch_unwind + // so a panic in serde / config validation doesn't unwind across FFI. + let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config))); + match load_result { + Ok(Ok(())) => RC_OK, + Ok(Err(e)) => { + tracing::error!("cpex_load_config: load_config failed: {}", e); + RC_PIPELINE_ERROR + } + Err(_panic) => { + tracing::error!("cpex_load_config: panic caught at FFI boundary"); + RC_PANIC + } + } +} + +/// Initialize all registered plugins. +/// +/// 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() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + match run_safely(inner.manager.initialize(), "cpex_initialize") { + SafeRun::Ok(Ok(())) => RC_OK, + SafeRun::Ok(Err(e)) => { + tracing::error!("cpex_initialize: {}", e); + RC_PIPELINE_ERROR + } + other => other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + } +} + +/// Shutdown all plugins and free the manager. +/// +/// `mgr` 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 +/// 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() { + return; + } + let inner = Box::from_raw(mgr); + // 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"); +} + +/// Check if any plugins are registered for a hook name. +/// +/// 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] +pub unsafe extern "C" fn cpex_has_hooks_for( + mgr: *const CpexManagerInner, + hook_name: *const c_char, + hook_len: c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return 0, + }; + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return 0, + }; + if inner.manager.has_hooks_for(name) { + 1 + } else { + 0 + } +} + +/// Get the number of registered plugins. +/// +/// 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, + None => 0, + } +} + +/// Whether the manager 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). +/// +/// # 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, + _ => 0, + } +} + +/// Get the names of all registered plugins as MessagePack-encoded +/// `Vec`. Caller must free the returned bytes with +/// `cpex_free_bytes`. +/// +/// Returns an `RC_*` code; on success the names are written to +/// `*names_msgpack_out` / `*names_len_out`. +/// +/// # Safety +/// `mgr` must be valid. Output pointers must be writable. +#[no_mangle] +pub unsafe extern "C" fn cpex_plugin_names( + mgr: *const CpexManagerInner, + names_msgpack_out: *mut *mut u8, + names_len_out: *mut c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let names = inner.manager.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; + RC_OK +} + +// --------------------------------------------------------------------------- +// Hook Invocation +// --------------------------------------------------------------------------- + +/// Invoke a hook by name. +/// +/// Payload and extensions are passed as MessagePack bytes. +/// ContextTable is an opaque handle (NULL for first invocation). +/// Returns MessagePack-encoded PipelineResult + opaque handles for +/// context table and background tasks. +/// +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// All pointer parameters must be valid or NULL where documented. +/// `mgr` is `*const` — `PluginManager::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] +pub unsafe extern "C" fn cpex_invoke( + mgr: *const CpexManagerInner, + hook_name: *const c_char, + hook_len: c_int, + payload_type: u8, + payload_msgpack: *const u8, + payload_len: c_int, + extensions_msgpack: *const u8, + extensions_len: c_int, + context_table: *mut CpexContextTableInner, // NULL for first call + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + context_table_out: *mut *mut CpexContextTableInner, + bg_handle_out: *mut *mut CpexBackgroundTasksInner, +) -> c_int { + // Validate manager handle + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + // Parse hook name + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + // Deserialize payload using the type registry + let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + + let payload: Box = match deserialize_payload(payload_type, payload_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!("cpex_invoke: {}", e); + return RC_PARSE_ERROR; + } + }; + + // Deserialize extensions from MessagePack + let extensions: Extensions = if extensions_len > 0 { + let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + match rmp_serde::from_slice(ext_bytes) { + Ok(e) => e, + Err(e) => { + tracing::error!("cpex_invoke: extensions deserialize failed: {}", e); + return RC_PARSE_ERROR; + } + } + } else { + Extensions::default() + }; + + // Get or create context table + let ctx_table: Option = if context_table.is_null() { + None + } else { + let ct = Box::from_raw(context_table); + Some(ct.table) + }; + + // Invoke the hook with wall-clock timeout + panic catch. + let (mut result, bg) = match run_safely( + inner + .manager + .invoke_by_name(name, payload, extensions, ctx_table), + "cpex_invoke", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + }; + + // Serialize modified payload using the type registry. A failure + // here is partial — the rest of the result (continue_processing, + // violation, metadata, modified_extensions) is still valid — so + // we surface the issue as a synthetic FFI-layer record in + // `result.errors` rather than failing the whole call. This is + // uniform with how the pipeline reports plugin-level errors + // swallowed by `on_error: ignore` / `on_error: disable`. + let (result_payload_type, modified_payload_bytes) = match result.modified_payload.as_ref() { + None => (payload_type, None), + Some(p) => match serialize_payload(p.as_ref()) { + 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, + }); + (payload_type, None) + } + }, + }; + + // Serialize modified extensions if present + let modified_extensions_bytes: Option> = result + .modified_extensions + .as_ref() + .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + + // Build FFI result. `errors` flows through verbatim — it's already + // PluginErrorRecord which is the canonical wire shape. + let ffi_result = FfiPipelineResult { + continue_processing: result.continue_processing, + violation: result.violation, + errors: result.errors, + metadata: result.metadata, + payload_type: result_payload_type, + modified_payload: modified_payload_bytes, + modified_extensions: modified_extensions_bytes, + }; + + let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { + Ok(b) => b, + Err(e) => { + tracing::error!("cpex_invoke: result serialize failed: {}", e); + return RC_SERIALIZE_ERROR; + } + }; + + // Return result bytes + let (ptr, len) = alloc_bytes(&result_bytes); + *result_msgpack_out = ptr; + *result_len_out = len; + + // Return context table as opaque handle + *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { + table: result.context_table, + })); + + // Return background tasks as opaque handle + *bg_handle_out = Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: bg })); + + RC_OK +} + +// --------------------------------------------------------------------------- +// Background Tasks +// --------------------------------------------------------------------------- + +/// Wait for all background tasks to complete. +/// +/// Returns MessagePack-encoded errors (empty array if none). +/// Returns 0 on success, -1 on failure. +/// +/// # 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] +pub unsafe extern "C" fn cpex_wait_background( + mgr: *const CpexManagerInner, + bg_handle: *mut CpexBackgroundTasksInner, + errors_msgpack_out: *mut *mut u8, + errors_len_out: *mut c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => { + // Consume `bg_handle` even on the failure path — the Go + // caller has already nil'd its reference, so without + // 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)); + } + return RC_INVALID_HANDLE; + } + }; + + if bg_handle.is_null() { + let empty: Vec = Vec::new(); + let (ptr, len) = alloc_bytes(&rmp_serde::to_vec_named(&empty).unwrap()); + *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 + // 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") { + SafeRun::Ok(errs) => errs, + other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + }; + + // 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 + .iter() + .map(cpex_core::error::PluginErrorRecord::from) + .collect(); + let error_bytes = match rmp_serde::to_vec_named(&ffi_errors) { + Ok(b) => b, + Err(_) => return RC_SERIALIZE_ERROR, + }; + + let (ptr, len) = alloc_bytes(&error_bytes); + *errors_msgpack_out = ptr; + *errors_len_out = len; + + RC_OK +} + +/// Free a background tasks handle without waiting. +/// +/// Tasks continue running in the tokio runtime. +/// +/// # Safety +/// `bg_handle` must be valid or NULL. +#[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)); + } +} + +// --------------------------------------------------------------------------- +// Context Table +// --------------------------------------------------------------------------- + +/// Free a context table handle. +/// +/// # Safety +/// `ct` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_context_table(ct: *mut CpexContextTableInner) { + if !ct.is_null() { + drop(Box::from_raw(ct)); + } +} + +// --------------------------------------------------------------------------- +// Memory Management +// --------------------------------------------------------------------------- + +/// Free a byte buffer allocated by the FFI layer. +/// +/// # Safety +/// `ptr` must have been allocated by this library (from `cpex_invoke` +/// or `cpex_wait_background`). `len` must match the original allocation. +#[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).unwrap(); + std::alloc::dealloc(ptr, layout); +} + +// --------------------------------------------------------------------------- +// FFI Result Types — serialized to MessagePack for the caller +// --------------------------------------------------------------------------- + +/// Pipeline result serialized across the FFI boundary. +/// Matches the Go `PipelineResult` struct field names. +/// +/// `errors` carries records from `on_error: ignore` / `on_error: disable` +/// plugins so the Go caller can surface them programmatically rather +/// than parsing log output. Fire-and-forget errors come through +/// `BackgroundTasks::wait_for_background_tasks()` instead. +#[derive(serde::Serialize, serde::Deserialize)] +struct FfiPipelineResult { + continue_processing: bool, + #[serde(skip_serializing_if = "Option::is_none")] + violation: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + errors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + /// Payload type ID — tells the Go caller how to deserialize. + payload_type: u8, + /// Modified payload as raw MessagePack bytes (if a plugin modified it). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "serde_bytes_opt")] + modified_payload: Option>, + /// Modified extensions as raw MessagePack bytes (if a plugin modified them). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "serde_bytes_opt")] + modified_extensions: Option>, +} + +/// Helper for serializing Option> as binary in MessagePack. +mod serde_bytes_opt { + use serde::{Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(bytes) => serde::Serialize::serialize(&serde_bytes::Bytes::new(bytes), s), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + use serde::Deserialize; + Option::::deserialize(d).map(|o| o.map(|b| b.into_vec())) + } +} + +// --------------------------------------------------------------------------- +// Generic Payload — wraps a deserialized MessagePack value +// --------------------------------------------------------------------------- + +/// A generic payload that wraps a deserialized serde_json::Value. +/// +/// Used for FFI dispatch when the concrete payload type isn't known +/// at compile time. The value was deserialized from MessagePack on +/// the Go side and will be passed to Rust plugins as-is. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenericPayload { + pub value: serde_json::Value, +} + +cpex_core::impl_plugin_payload!(GenericPayload); + +// --------------------------------------------------------------------------- +// FFI unit tests +// --------------------------------------------------------------------------- +// +// 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. + +#[cfg(test)] +mod tests { + use super::*; + use std::ptr; + 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}; + + // --- Test scaffolding ----------------------------------------------------- + + /// Test hook type using GenericPayload — that's the type + /// PAYLOAD_GENERIC produces at the FFI deserialization boundary, + /// so the typed-adapter downcast actually finds the handler. + /// (Defining a custom TestPayload would mean the executor's + /// downcast finds None and the handler never runs.) + struct TestHook; + impl HookTypeDef for TestHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "test_hook"; + } + + /// A plugin whose handler always panics — exercises the `catch_unwind` + /// path inside `run_safely`. + struct PanickingPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for PanickingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + impl cpex_core::hooks::HookHandler for PanickingPlugin { + fn handle( + &self, + _payload: &GenericPayload, + _extensions: &Extensions, + _ctx: &mut cpex_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 { + // Touch the shared runtime so it's initialized; tests use it + // rather than a per-manager runtime. + let _ = shared_runtime(); + let manager = cpex_core::manager::PluginManager::default(); + Box::into_raw(Box::new(CpexManagerInner { manager })) + } + + fn register_panicking_plugin(mgr: &CpexManagerInner) { + let cfg = PluginConfig { + name: "panicker".into(), + kind: "test".into(), + hooks: vec!["test_hook".into()], + mode: PluginMode::Sequential, + ..Default::default() + }; + let plugin = Arc::new(PanickingPlugin { cfg: cfg.clone() }); + mgr.manager + .register_handler::(plugin, cfg) + .expect("register"); + } + + /// Encode a generic JSON value to MessagePack, the wire format + /// PAYLOAD_GENERIC consumes. Returns bytes the FFI can borrow. + fn payload_bytes(value: &str) -> Vec { + rmp_serde::to_vec_named(&serde_json::json!({ "value": value })).expect("encode payload") + } + + /// 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, + payload_type: u8, + payload: &[u8], + ) -> c_int { + 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, + ); + + // 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); + } + rc + } + + // --- Tests ---------------------------------------------------------------- + + /// 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). Direct regression for P0 #2. + #[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 drop(&mut self) { + unsafe { + cpex_shutdown(self.0); + } + } + } + let _guard = ManagerGuard(mgr); + + unsafe { + let inner = &*mgr; + register_panicking_plugin(inner); + // Manager must be initialized for the pipeline to dispatch. + let init_rc = cpex_initialize(mgr); + 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, + ); + } + } + + /// Invoking with an unknown `payload_type` must return + /// `RC_PARSE_ERROR` — the deserialize_payload registry rejects + /// unknown discriminators with a typed error code rather than a + /// 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 { + fn drop(&mut self) { + unsafe { + cpex_shutdown(self.0); + } + } + } + let _guard = ManagerGuard(mgr); + + unsafe { + let inner = &*mgr; + register_panicking_plugin(inner); // need *some* plugin so dispatch runs + assert_eq!(cpex_initialize(mgr), 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); + assert_eq!( + rc, RC_PARSE_ERROR, + "unknown payload_type should map to RC_PARSE_ERROR, got {}", + rc, + ); + } + } + + /// `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. + #[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); + } + + /// `cpex_configure_runtime` rejects non-positive worker counts + /// before touching the shared runtime — the early bounds check + /// fires regardless of OnceLock state, so this is order-independent. + #[test] + fn cpex_configure_runtime_rejects_non_positive_workers() { + assert_eq!(cpex_configure_runtime(0), RC_INVALID_INPUT); + assert_eq!(cpex_configure_runtime(-1), RC_INVALID_INPUT); + } + + /// Once the shared runtime is initialized (e.g., by any prior test + /// or `cpex_manager_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() { + // Touch the runtime to ensure it's initialized. This may already + // have happened in another test; either way OnceLock is set. + let _ = shared_runtime(); + // Configure should now refuse: window has closed. + assert_eq!(cpex_configure_runtime(2), RC_INVALID_INPUT); + } + + /// `serialize_payload` returns `Ok` for known registered types so + /// modifications round-trip cleanly. + #[test] + fn serialize_payload_round_trips_generic() { + let gp = GenericPayload { + value: serde_json::json!({ "k": "v" }), + }; + let (t, bytes) = serialize_payload(&gp).expect("known type should serialize"); + assert_eq!(t, PAYLOAD_GENERIC); + // Confirm the encoded bytes deserialize back to the same shape + // — guards against silent type-id/wire-format drift. + let value: serde_json::Value = rmp_serde::from_slice(&bytes).expect("round-trip decode"); + assert_eq!(value, serde_json::json!({ "k": "v" })); + } + + /// `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 + /// `modified_payload = None` even though one was produced. + /// This test pins the new error contract so that regression can't + /// reappear. + #[test] + fn serialize_payload_returns_err_for_unknown_type() { + // A custom PluginPayload impl that's not in the FFI registry — + // simulates a plugin returning a custom payload type the + // serializer doesn't know how to ship across the wire. + #[derive(Clone)] + struct CustomPayload; + impl PluginPayload for CustomPayload { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + } + let err = serialize_payload(&CustomPayload).expect_err("unknown type should err"); + assert!( + err.contains("unknown payload type"), + "error message should identify the failure mode, got: {err}", + ); + } + + /// A null manager 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() { + unsafe { + let bytes = payload_bytes("x"); + let rc = invoke_for_test(ptr::null(), PAYLOAD_GENERIC, &bytes); + assert_eq!(rc, RC_INVALID_HANDLE); + + assert_eq!(cpex_initialize(ptr::null()), RC_INVALID_HANDLE); + assert_eq!(cpex_is_initialized(ptr::null()), 0); + } + } +} diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs index 6b25f15b..e4ef19f4 100644 --- a/crates/cpex-sdk/src/lib.rs +++ b/crates/cpex-sdk/src/lib.rs @@ -14,9 +14,7 @@ pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; // Hook system -pub use cpex_core::hooks::{ - Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, -}; +pub use cpex_core::hooks::{Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult}; // Context pub use cpex_core::context::PluginContext; @@ -29,11 +27,25 @@ pub use cpex_core::define_hook; // CMF types pub use cpex_core::cmf::{ - // Message and payload - CmfHook, Message, MessagePayload, - // Enums - Channel, ContentType, ResourceType, Role, // Content parts and domain objects - AudioSource, ContentPart, DocumentSource, ImageSource, PromptRequest, PromptResult, Resource, - ResourceReference, ToolCall, ToolResult, VideoSource, + 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/docs/specs/cpex-go-spec.md b/docs/specs/cpex-go-spec.md new file mode 100644 index 00000000..55d21757 --- /dev/null +++ b/docs/specs/cpex-go-spec.md @@ -0,0 +1,1107 @@ +# CPEX Go — Public API Specification + +**Status**: Draft +**Date**: May 2026 +**Source**: `github.com/contextforge-org/contextforge-plugins-framework/go/cpex` + +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. + +## 1. Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Go Host (e.g., AuthBridge) │ +│ │ +│ PluginManager ───────────────────────────────┐ │ +│ │ NewPluginManager[Default]() │ │ +│ │ RegisterFactories(fn) │ │ +│ │ LoadConfig(yaml) │ │ +│ │ Initialize() │ │ +│ │ InvokeByName(hook, payload, ext, ctx) │ │ +│ │ Invoke[P](hook, payload, ext, ctx) │ │ +│ │ HasHooksFor(hook) / PluginCount() │ │ +│ │ Shutdown() │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ CGo / MessagePack │ +├────────────────────────┼─────────────────────────────┤ +│ libcpex_ffi (Rust) ▼ │ +│ cpex_manager_new / cpex_invoke / cpex_shutdown │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ cpex-core (Rust) │ │ +│ │ • PluginManager → Executor → Plugins │ │ +│ │ • tokio runtime (async plugin execution) │ │ +│ │ • Phase ordering, capability gating │ │ +│ │ • Route resolution, policy composition │ │ +│ └─────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +**Key design decisions:** + +- 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. + +## 2. Package & Import + +```go +import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +``` + +**Dependencies:** + +| Dependency | Purpose | +|---|---| +| `github.com/vmihailenco/msgpack/v5` | MessagePack serialization across FFI | + +**Build requirements:** + +```bash +# Build the Rust FFI library first +cargo build --release -p cpex-ffi + +# Then build/test Go code +go test -v ./... +``` + +CGo links against `libcpex_ffi` from `target/release/`. + +## 3. Lifecycle + +``` +[ConfigureRuntime(N)] ← optional, package-level, before any manager + │ + ▼ +NewPluginManagerDefault() + │ + ▼ +RegisterFactories(fn) ← register Rust plugin factories via callback + │ + ▼ +LoadConfig(yaml) ← YAML with plugin definitions, routing, policies + │ + ▼ +Initialize() ← instantiate and wire all plugins + │ + ▼ +InvokeByName / Invoke[P] ← dispatch hooks (repeatable) + │ + ▼ +Shutdown() ← graceful teardown +``` + +## 4. Quick Reference + +| Operation | Method | +|---|---| +| Configure runtime (optional) | `cpex.ConfigureRuntime(workerThreads)` | +| Create manager | `NewPluginManagerDefault()` or `NewPluginManager(yaml)` | +| Register factories | `mgr.RegisterFactories(fn)` | +| Load config | `mgr.LoadConfig(yaml)` | +| Initialize | `mgr.Initialize()` | +| Query lifecycle | `mgr.IsInitialized()` | +| Check hooks exist | `mgr.HasHooksFor(hookName)` | +| Count plugins | `mgr.PluginCount()` | +| List plugins | `mgr.PluginNames()` | +| Invoke (untyped) | `mgr.InvokeByName(hook, type, payload, ext, ctx)` | +| Invoke (typed) | `Invoke[P](mgr, hook, type, payload, ext, ctx)` | +| Check denial | `result.IsDenied()` | +| Get violation | `result.Violation` | +| Get pipeline errors | `result.Errors` (from `on_error: ignore`/`disable` plugins) | +| 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) | +| Shutdown | `mgr.Shutdown()` | + +## 5. Core Types + +### 5.1 PluginManager + +The top-level object. Owns the Rust runtime and plugin registry. + +```go +type PluginManager struct { /* opaque CGo handle, sync.RWMutex */ } + +// Construction +func NewPluginManager(yaml string) (*PluginManager, error) +func NewPluginManagerDefault() (*PluginManager, error) + +// Factory registration +func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error + +// Configuration +func (m *PluginManager) LoadConfig(yaml string) error + +// Initialization +func (m *PluginManager) 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) + +// Invocation +func (m *PluginManager) InvokeByName( + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) + +// Typed invocation (generics) +func Invoke[P any]( + m *PluginManager, + hookName string, + payloadType uint8, + payload P, + extensions *Extensions, + contextTable *ContextTable, +) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) + +// Teardown +func (m *PluginManager) 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. +- 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. +- `PluginNames()` returns the registered plugin names in registration order (no guaranteed sort). + +### 5.2 FactoryRegistrar + +```go +type FactoryRegistrar func(handle unsafe.Pointer) error +``` + +A callback that receives the raw C manager handle. The caller uses this to invoke their own `extern "C"` factory registration function. This is the bridge for registering custom Rust plugin factories that are compiled into a separate shared library. + +**Example:** + +```go +/* +#include +int my_register_factories(void* mgr); +*/ +import "C" + +err := mgr.RegisterFactories(func(handle unsafe.Pointer) error { + rc := C.my_register_factories(handle) + if rc != 0 { + return fmt.Errorf("factory registration failed: %d", rc) + } + return nil +}) +``` + +### 5.3 ContextTable + +```go +type ContextTable struct { /* opaque CGo handle */ } +func (ct *ContextTable) Close() +``` + +Per-plugin state that persists across hook invocations within a single request. Thread the returned `ContextTable` from one `Invoke` call into the next to maintain plugin-local context. + +- Pass `nil` on the first invocation. +- After use, the handle is consumed by the next `Invoke` call (ownership transfers to Rust). +- Call `Close()` to release without further use. + +### 5.4 BackgroundTasks + +```go +type BackgroundTasks struct { /* opaque CGo handle */ } +func (bg *BackgroundTasks) Wait() ([]PluginError, error) +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). + +### 5.5 PipelineResult + +```go +type PipelineResult struct { + ContinueProcessing bool + Violation *PluginViolation + Metadata map[string]any + PayloadType uint8 + ModifiedPayload []byte // raw MessagePack + ModifiedExtensions []byte // raw MessagePack + Errors []PluginError // see §5.7 +} + +func (r *PipelineResult) IsDenied() bool +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. + +### 5.6 TypedPipelineResult + +```go +type TypedPipelineResult[P any] struct { + ContinueProcessing bool + Violation *PluginViolation + Metadata map[string]any + PayloadType uint8 + ModifiedPayload *P + ModifiedExtensions *Extensions + Errors []PluginError +} + +func (r *TypedPipelineResult[P]) IsDenied() bool +``` + +The typed invoke path (`Invoke[P]`) automatically deserializes the modified payload and extensions into concrete Go types. `Errors` is the same shape as `PipelineResult.Errors`. + +### 5.7 PluginError + +```go +type PluginError struct { + PluginName string `msgpack:"plugin_name"` + Message string `msgpack:"message"` + Code string `msgpack:"code,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} +``` + +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:** + +| `PluginName` | Source | +|---|---| +| `` | Framework-emitted at the FFI boundary. Currently issued when a plugin's modified payload cannot be re-serialized across the wire (`Code: "ffi_serialize_error"`). The rest of the result remains valid; the failure is surfaced via `Errors` rather than failing the whole call. | + +Filter or branch by `PluginName == ""` if your host wants to distinguish FFI-layer failures from plugin-emitted failures. + +### 5.8 PluginViolation + +```go +type PluginViolation struct { + Code string + Reason string + Description string + Details map[string]any + PluginName string + ProtoErrorCode *int64 +} +``` + +Structured denial. `Code` is a machine-readable identifier; `Reason` is a short human-readable explanation. + +### 5.9 ConfigureRuntime (package-level) + +```go +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. + +```go +// In main(), before any manager construction: +if err := cpex.ConfigureRuntime(8); err != nil { + log.Fatal(err) // returns ErrCpexInvalidInput on <=0 or after init +} +``` + +**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. + +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. + +## 6. Extensions + +Extensions carry capability-gated metadata alongside the payload. Each plugin sees only the extensions its declared capabilities grant. Serialized as MessagePack across the FFI boundary. + +```go +type Extensions struct { + Meta *MetaExtension + Security *SecurityExtension + Http *HttpExtension + Delegation *DelegationExtension + Agent *AgentExtension + Request *RequestExtension + MCP *MCPExtension + Completion *CompletionExtension + Provenance *ProvenanceExtension + LLM *LLMExtension + Framework *FrameworkExtension + Custom map[string]any +} +``` + +### 6.1 Extension Types + +| Extension | Purpose | Key Fields | +|---|---|---| +| `Meta` | Entity identification for route resolution | `EntityType`, `EntityName`, `Tags`, `Scope`, `Properties` | +| `Security` | Identity, labels, data policies | `Subject`, `Agent`, `Labels`, `Classification`, `AuthMethod`, `Objects`, `Data` | +| `Http` | HTTP request/response context | `RequestHeaders`, `ResponseHeaders` | +| `Delegation` | Token delegation chain | `Chain[]`, `Depth`, `OriginSubjectID`, `ActorSubjectID` | +| `Agent` | Agent execution context | `Input`, `SessionID`, `ConversationID`, `Turn` (`*uint32`), `AgentID`, `ParentAgentID`, `Conversation` (`*ConversationContext`) | +| `Request` | Execution environment and tracing | `Environment`, `RequestID`, `TraceID`, `SpanID`, `Timestamp` | +| `MCP` | MCP entity metadata | `Tool`, `Resource`, `Prompt` | +| `Completion` | LLM completion stats | `StopReason`, `Tokens`, `Model`, `RawFormat`, `CreatedAt`, `LatencyMs` | +| `Provenance` | Origin and message threading | `Source`, `MessageID`, `ParentID` | +| `LLM` | Model identity | `ModelID`, `Provider`, `Capabilities` | +| `Framework` | Agentic framework context | `Framework`, `FrameworkVersion`, `NodeID`, `GraphID` | +| `Custom` | Arbitrary key-value pairs | `map[string]any` | + +### 6.2 Security Extension Detail + +```go +type SecurityExtension struct { + Labels []string + Classification string + Subject *SubjectExtension // authenticated caller + Agent *AgentIdentity // this agent's workload identity + AuthMethod string + Objects map[string]ObjectSecurityProfile + Data map[string]DataPolicy +} + +type SubjectExtension struct { + ID, SubjectType string + Roles, Permissions, Teams []string + Claims map[string]string +} + +type AgentIdentity struct { + ClientID, WorkloadID, TrustDomain string +} +``` + +### 6.3 Delegation Extension Detail + +```go +type DelegationExtension struct { + Chain []DelegationHop + Depth int + OriginSubjectID string + ActorSubjectID string + Delegated bool + AgeSeconds float64 +} + +type DelegationHop struct { + SubjectID, SubjectType, Audience, Strategy, Timestamp string + ScopesGranted []string + TTLSeconds *uint64 + FromCache bool +} +``` + +### 6.4 Capability-Gated Writes (Rust Plugin Side) + +The `capabilities` list in a plugin's YAML config controls which extension fields the plugin can read **and** write. The Rust executor translates declared capabilities into write tokens before calling `Plugin::handle`. A plugin that lacks `write_headers`, for example, receives `http_write_token: None` and cannot modify `HttpExtension`. + +The Rust write pattern uses COW (copy-on-write) ownership: + +```rust +// 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 + 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-CPEX-Processed", "true"); + } +} + +PluginResult::modify_extensions(owned) // emit modified extensions back to Go +``` + +On the Go side, `result.ModifiedExtensions` (or `typed.ModifiedExtensions`) carries the updated extensions returned by the plugin. The Go caller can deserialize them with `result.DeserializeExtensions()` (see §13.3). + +**Rust `PluginResult` constructors:** + +| Constructor | What it signals | +|---|---| +| `PluginResult::allow()` | Pass, no changes | +| `PluginResult::deny(violation)` | Halt pipeline, return violation to Go | +| `PluginResult::modify_extensions(owned)` | Pass, return modified extensions | +| `PluginResult::modify_payload(payload)` | Pass, return modified payload | + +## 7. Payload Types + +### 7.1 Payload Type Registry + +CPEX uses a `payloadType` discriminator to tell the Rust core how to deserialize the payload: + +| Constant | Value | Payload Type | +|---|---|---| +| `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. + +### 7.2 Generic Payload + +Any `map[string]any` or struct with msgpack tags. Serialized as MessagePack, deserialized in Rust as a `serde_json::Value`. + +```go +payload := map[string]any{ + "tool_name": "get_compensation", + "user": "alice", +} +result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", cpex.PayloadGeneric, payload, ext, nil) +``` + +### 7.3 CMF MessagePayload + +The ContextForge Message Format — a typed, multi-part message with schema versioning. + +```go +type MessagePayload struct { + Message Message `msgpack:"message"` +} + +type Message struct { + SchemaVersion string `msgpack:"schema_version"` + Role string `msgpack:"role"` + Content []ContentPart `msgpack:"content"` + Channel string `msgpack:"channel,omitempty"` +} + +func NewMessage(role string, content ...ContentPart) Message +``` + +### 7.4 Content Parts + +`ContentPart` is a tagged union discriminated by `content_type`. Custom msgpack encoding produces the same wire format as Rust's `#[serde(tag = "content_type")]`. + +| Content Type | Constructor | Data Field | +|---|---|---| +| `text` | `NewTextPart(s)` | `.Text` | +| `thinking` | `NewThinkingPart(s)` | `.Text` | +| `tool_call` | `NewToolCallPart(tc)` | `.ToolCallContent` | +| `tool_result` | `NewToolResultPart(tr)` | `.ToolResultContent` | +| `resource` | `NewResourcePart(r)` | `.ResourceContent` | +| `resource_ref` | `NewResourceRefPart(r)` | `.ResourceRefContent` | +| `prompt_request` | `NewPromptRequestPart(pr)` | `.PromptRequestContent` | +| `prompt_result` | `NewPromptResultPart(pr)` | `.PromptResultContent` | +| `image` | `NewImagePart(img)` | `.ImageContent` | +| `video` | `NewVideoPart(vid)` | `.VideoContent` | +| `audio` | `NewAudioPart(aud)` | `.AudioContent` | +| `document` | `NewDocumentPart(doc)` | `.DocumentContent` | + +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. + +**Example:** + +```go +msg := cpex.MessagePayload{ + Message: cpex.NewMessage("assistant", + cpex.NewTextPart("Looking up compensation data"), + cpex.NewToolCallPart(cpex.ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), +} + +result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( + mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, msg, ext, nil, +) +``` + +## 8. Hook Types (Built-in) + +Hooks are open strings — hosts define their own. The following are built into `cpex-core`: + +### 8.1 Legacy Hooks (typed payloads) + +| 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) | + +### 8.3 Custom Hooks + +Hosts register their own hook names. Any string works: + +```go +mgr.InvokeByName("inbound.pre_validation", cpex.PayloadGeneric, payload, ext, nil) +mgr.InvokeByName("outbound.pre_exchange", cpex.PayloadGeneric, payload, ext, nil) +``` + +## 9. Plugin Configuration (YAML) + +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] + +plugins: + - name: identity-checker + 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 + capabilities: + - read_labels + - read_subject + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + + - name: header-injector + kind: builtin/cmf-header-injector + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 50 + on_error: ignore + capabilities: + - read_headers + - write_headers + +routes: + # Tool-specific route — tags applied to all invocations of this tool + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + - tool: list_departments + plugins: + - audit-logger + + # Wildcard route — applies to all tools not matched above + - tool: "*" + plugins: + - audit-logger +``` + +### 9.1 Plugin Modes + +| Mode | Behavior | +|---|---| +| `sequential` | Serial execution, can block (deny) AND modify payload | +| `transform` | Serial execution, can modify payload but cannot block | +| `audit` | Serial execution, read-only (no modify, no block) | +| `concurrent` | Parallel execution, can block but cannot modify | +| `fire_and_forget` | Background execution, non-blocking, runs after pipeline completes | +| `disabled` | Plugin loaded but not executed | + +### 9.2 Error Handling (`on_error`) + +| Value | Behavior | +|---|---| +| `fail` | Halt pipeline, propagate error to caller | +| `ignore` | Log error, continue pipeline | +| `disable` | Log error, disable plugin for remaining lifetime, continue | + +### 9.3 Plugin Capabilities + +The optional `capabilities` list controls which extension fields a plugin can read and write. The Rust executor passes write tokens only for declared capabilities; undeclared extension slots arrive as `None` in the plugin's `handle()` call. + +| Capability | Extensions access granted | +|---|---| +| `read_labels` | `SecurityExtension.labels` (read) | +| `read_subject` | `SecurityExtension.subject` (read) | +| `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. + +### 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. + +```yaml +routes: + # Exact match — injects meta tags for this tool's invocations + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Exact match — no meta tags + - tool: list_departments + plugins: + - audit-logger + + # Wildcard — catch-all for remaining tools + - tool: "*" + plugins: + - 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. + +## 10. Integration Pattern + +The canonical integration pattern for a Go host: + +```go +package main + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +/* +// macOS: add -framework CoreFoundation -framework Security +// Linux: -lm -ldl -lpthread are sufficient +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lmy_plugins_ffi -lm -ldl -lpthread +#include +int my_register_factories(void* mgr); +*/ +import "C" + +func main() { + // 1. Create manager + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + panic(err) + } + defer mgr.Shutdown() + + // 2. Register custom plugin factories + if err := mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.my_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil + }); err != nil { + panic(err) + } + + // 3. Load configuration + yaml, err := os.ReadFile("plugins.yaml") + if err != nil { + panic(err) + } + if err := mgr.LoadConfig(string(yaml)); err != nil { + panic(err) + } + + // 4. Initialize plugins + if err := mgr.Initialize(); err != nil { + panic(err) + } + + // 5. Invoke hooks in the request lifecycle + ext := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + Security: &cpex.SecurityExtension{ + Subject: &cpex.SubjectExtension{ + ID: "user-123", + Roles: []string{"analyst"}, + }, + }, + } + + result, ct, bg, err := mgr.InvokeByName( + "tool_pre_invoke", cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "user": "alice"}, + ext, nil, + ) + if err != nil { + panic(err) + } + + if result.IsDenied() { + fmt.Printf("Denied: %s [%s]\n", result.Violation.Reason, result.Violation.Code) + ct.Close() + bg.Close() + return + } + + // 6. Thread context into post-invoke + result2, ct2, bg2, err := mgr.InvokeByName( + "tool_post_invoke", cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "result": "..."}, + ext, ct, // pass context from pre-invoke + ) + if err != nil { + panic(err) + } + _ = result2 + bg.Close() + bg2.Close() + ct2.Close() +} +``` + +## 11. Typed Invoke Pattern + +For hosts using CMF messages or custom structs with strong typing: + +```go +// Define a custom payload type with msgpack tags +type InboundPreValidationPayload struct { + Path string `msgpack:"path"` + Audience string `msgpack:"audience"` +} + +// Invoke with type safety +result, ct, bg, err := cpex.Invoke[InboundPreValidationPayload]( + mgr, + "inbound.pre_validation", + cpex.PayloadGeneric, // serialized as generic msgpack + InboundPreValidationPayload{Path: "/api/v1/users", Audience: "my-api"}, + ext, + nil, +) +if err != nil { /* handle */ } + +// result.ModifiedPayload is *InboundPreValidationPayload (or nil if unmodified) +if result.ModifiedPayload != nil { + fmt.Println("Modified audience:", result.ModifiedPayload.Audience) +} +``` + +## 12. Zero-Cost Guard Pattern + +Check for registered plugins before constructing payloads: + +```go +if !mgr.HasHooksFor("inbound.pre_validation") { + // No plugins configured — skip payload construction and FFI overhead + return handleRequestDirectly(req) +} + +// Only build payload and extensions if plugins are registered +payload := buildPreValidationPayload(req) +ext := buildExtensions(req) +result, ct, bg, err := mgr.InvokeByName("inbound.pre_validation", ...) +``` + +This pattern ensures zero cost when no plugins are configured for a hook point. + +## 13. Result Handling + +### 13.1 Allow/Deny + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if result.IsDenied() { + // Pipeline halted by a plugin + v := result.Violation + return denyResponse(v.Code, v.Reason, v.Description) +} +// Proceed with original or modified payload +``` + +### 13.2 Modified Payload + +```go +// Raw path — manual deserialization +if len(result.ModifiedPayload) > 0 { + modified, err := cpex.DeserializePayload[MyPayload](result) + // use modified +} + +// 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 +} +``` + +### 13.3 Modified Extensions + +```go +if len(result.ModifiedExtensions) > 0 { + ext, _ := result.DeserializeExtensions() + // Plugins may have enriched Security.Subject, added Labels, etc. +} +``` + +### 13.4 Background Tasks + +```go +// 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 + // 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) +} +for _, e := range bgErrors { + log.Warn("background task error: plugin=%s code=%s msg=%s", + e.PluginName, e.Code, e.Message) +} + +// Option B: Fire and forget +bg.Close() +``` + +### 13.5 Metadata + +```go +if result.Metadata != nil { + // Aggregate metadata from all plugins in the chain + if decision, ok := result.Metadata["_decision_plugin"]; ok { + log.Info("decided by:", decision) + } +} +``` + +### 13.6 Pipeline Errors (ignore / disable) + +When a plugin fails and its `on_error` mode is `ignore` or `disable`, the pipeline continues and the failure is recorded in `result.Errors` rather than halting via `result.Violation`. This is the canonical surface for non-fatal plugin errors that callers may still want to act on. + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if err != nil { /* FFI-level error */ } +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 + // 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 + // 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) + } +} +``` + +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. + +### 14.1 Sentinels + +```go +var ( + // ErrCpexInvalidHandle: the manager handle is null or the + // manager was shut down. Returned when calling methods on a + // shut-down manager, or when BackgroundTasks.Wait runs after + // Shutdown. + ErrCpexInvalidHandle = errors.New("cpex: invalid handle ...") + + // 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") + + // ErrCpexParse: parse / deserialize failed (YAML config, + // MessagePack payload, MessagePack extensions). Often a wire + // format mismatch between Go and Rust struct definitions. + ErrCpexParse = errors.New("cpex: parse / deserialize 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 + // when on_error is ignore/disable. + 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 + // unserializable JSON value. Distinct from the per-modified- + // payload synthetic error in result.Errors (see §5.7). + ErrCpexSerialize = errors.New("cpex: result serialize failed") + + // ErrCpexTimeout: the FFI wall-clock timeout (60s) was + // 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") + + // 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") +) +``` + +### 14.2 Classification Pattern + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if err != nil { + switch { + case errors.Is(err, cpex.ErrCpexTimeout): + metrics.Inc("cpex.timeout") + return retryWithBackoff(req) + case errors.Is(err, cpex.ErrCpexPanic): + // Plugin bug — 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. + return errors.New("plugin runtime offline") + default: + // ErrCpexParse / Serialize / Pipeline / InvalidInput — + // typically caller or config bugs. + log.Error("cpex invoke:", err) + return err + } +} +``` + +### 14.3 Two error channels + +CPEX Go reports failures through two distinct channels, and they have different semantics: + +| 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 | + +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. + +## 15. Serialization + +All types use `msgpack` struct tags matching Rust field names for zero-copy serialization across the FFI boundary. The wire format is MessagePack with named fields (`rmp_serde::to_vec_named` on the Rust side). + +**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. +- `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. +- `BackgroundTasks` is safe to call `Wait()` or `Close()` from any goroutine, but only once. + +## 17. Gaps and Unimplemented Features + +The following features exist in the Python CPEX implementation but are not yet exposed in the Go API. These are tracked for future implementation: + +| Feature | Python Location | Status in Go | +|---|---|---| +| `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 | +| 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 | +| 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 | +| Isolated (subprocess) plugins | `framework/isolated/` | Not yet in Rust core | +| `retry_delay_ms` in result | `models.py` | Not exposed in FFI result | + +## 18. Build & Test + +The repo ships a Makefile with the canonical commands. The raw `cargo` / `go` invocations are still listed below for environments without `make`. + +### 18.1 Make targets (recommended) + +| Target | What it does | +|---|---| +| `make rust-build` / `rust-build-release` | Build Rust workspace (debug / release) | +| `make rust-test` | Full Rust workspace tests | +| `make rust-test-ffi` | Only the cpex-ffi crate tests | +| `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 go-build` | Build the Go cpex package (auto-rebuilds cdylib first) | +| `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-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) | + +`golangci-lint` is required for `go-lint*`; install with `brew install golangci-lint`. + +### 18.2 Raw commands + +```bash +# 1. Build the Rust FFI library +cargo build --release -p cpex-ffi + +# 2. Run Go tests (links against libcpex_ffi) +cd go/cpex && go test -count=1 -race ./... + +# 3. Run the demo (requires demo plugin library) +cd examples/go-demo/ffi && cargo build --release +cd examples/go-demo && go run . + +# 4. Run the CMF demo +cd examples/go-demo && go run ./cmd/cmf-demo +``` + +**Platform notes:** +- macOS: link with `-framework CoreFoundation -framework Security` +- Linux: link with `-lm -ldl -lpthread` +- The `#cgo LDFLAGS` directive in `ffi.go` points to `target/release/` + diff --git a/examples/go-demo/.gitignore b/examples/go-demo/.gitignore new file mode 100644 index 00000000..8123b755 --- /dev/null +++ b/examples/go-demo/.gitignore @@ -0,0 +1,3 @@ +# Built demo binaries +cpex-demo +cmf-demo diff --git a/examples/go-demo/README.md b/examples/go-demo/README.md new file mode 100644 index 00000000..cfc4016b --- /dev/null +++ b/examples/go-demo/README.md @@ -0,0 +1,349 @@ +# CPEX Go Demo + +Two runnable examples showing the full CPEX plugin pipeline from Go, with plugins written in Rust and loaded via YAML configuration. + +## Prerequisites + +- **Go 1.21+** +- **Rust toolchain** (stable, 1.75+) + +## Build + +```bash +# 1. Build the demo FFI library (includes core + demo plugins) +cd examples/go-demo/ffi +cargo build --release + +# 2. Build the Go demos +cd examples/go-demo +go build -o cpex-demo . +go build -o cmf-demo ./cmd/cmf-demo/ +``` + +## Demo 1: Generic Payload (`cpex-demo`) + +Uses `PayloadGeneric` (untyped `map[string]any`) with three plugins: + +| 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 | + +### Run + +```bash +cd examples/go-demo +./cpex-demo +``` + +### Expected output + +``` +=== CPEX Go Demo === + +Plugins loaded: 3 +Hooks: tool_pre_invoke=true tool_post_invoke=true + +=== Scenario 1: get_compensation (no PII clearance) === + Result: DENIED — PII clearance required for this operation [pii_access_denied] + +=== Scenario 2: get_compensation (with PII clearance) === + Result: ALLOWED + +=== Scenario 3: list_departments (non-PII tool) === + Result: ALLOWED + +=== Scenario 4: list_departments (no user identity) === + Result: DENIED — User identity is required [no_identity] +``` + +### Config + +See [`plugins.yaml`](plugins.yaml) for the full configuration including routing rules and policy groups. + +## Demo 2: CMF Payload (`cmf-demo`) + +Uses `PayloadCMFMessage` (typed CMF messages) with rich extensions and two plugins: + +| 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 | + +### Run + +```bash +cd examples/go-demo +./cmf-demo +``` + +### Expected output + +``` +=== CPEX CMF 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 + +=== Scenario 2: get_compensation tool call (with PII label) === + Result: ALLOWED + Modified response headers: + X-Tool-Name: get_compensation + X-Tool-Status: success + X-CPEX-Processed: true + +=== Scenario 3: tool result post-invoke (header injection) === + Result: ALLOWED + Modified response headers: + X-Tool-Name: get_compensation + ... +``` + +### Config + +See [`cmf_plugins.yaml`](cmf_plugins.yaml) for capabilities and routing. + +## Architecture + +``` +Go (main.go) + │ + │ cpex.NewPluginManagerDefault() + │ cpex.RegisterFactories(callback) ← one raw C call + │ cpex.LoadConfig(yaml) + │ cpex.Initialize() + │ cpex.InvokeByName(hook, payload, extensions, ...) + │ + ▼ +Go SDK (go/cpex/) + │ MessagePack serialize payload + extensions + │ + ▼ +cgo FFI (libcpex_demo_ffi.a) + │ cpex_invoke() → Rust executor + │ + ▼ +Rust Plugins (examples/go-demo/ffi/src/) + │ Plugin::handle() → PluginResult + │ + ▼ +cgo FFI + │ MessagePack serialize result + modified extensions + │ + ▼ +Go SDK + │ PipelineResult { IsDenied(), Violation, ModifiedExtensions } + │ + ▼ +Go (main.go) +``` + +## Demo Crate Structure + +``` +examples/go-demo/ + main.go — generic payload demo + plugins.yaml — config for generic demo + cmf_plugins.yaml — config for CMF demo + go.mod — Go module (depends on go/cpex) + cmd/ + cmf-demo/ + main.go — CMF payload demo + ffi/ + 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) + cmf_plugins.rs — 2 CMF plugins (tool-policy, header-injector) +``` + +The `cpex-demo-ffi` crate builds a staticlib that includes both the core `cpex-ffi` symbols and the demo plugin factories. Go links only this one library. + +## How Factory Registration Works + +The Go SDK's `PluginManager` wraps the Rust manager. 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 + // Call your crate's C registration function + C.cpex_demo_register_factories(handle) + return nil +}) +``` + +This keeps the Go SDK generic — it doesn't know about specific factories. Each Rust crate exports its own `register_*_factories()` function. + +--- + +# Adding New Payload Types and Hooks + +This section covers how to extend the system with new payload types for Go-to-Rust plugin pipelines. + +## Overview + +The CPEX payload type registry maps a `uint8` discriminator to a concrete Rust type for efficient deserialization across the FFI boundary. Currently: + +| ID | Constant | Rust Type | Go Type | +|----|----------|-----------|---------| +| 0 | `PAYLOAD_GENERIC` | `GenericPayload` | `map[string]any` | +| 1 | `PAYLOAD_CMF_MESSAGE` | `MessagePayload` | `MessagePayload` | + +## 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): + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MyPayload { + pub field_a: String, + pub field_b: i64, +} +cpex_core::impl_plugin_payload!(MyPayload); +``` + +### 2. Register the payload type in the FFI crate + +In `crates/cpex-ffi/src/lib.rs`: + +```rust +// Add the constant +pub const PAYLOAD_MY_TYPE: u8 = 2; + +// Add a deserialize arm +fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result<...> { + match payload_type { + // ... existing arms ... + PAYLOAD_MY_TYPE => { + let v: MyPayload = rmp_serde::from_slice(bytes)?; + Ok(Box::new(v)) + } + _ => Err(...) + } +} + +// Add a serialize arm +fn serialize_payload(payload: &dyn PluginPayload) -> Option<(u8, Vec)> { + // ... existing checks ... + if let Some(mp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(mp).ok().map(|b| (PAYLOAD_MY_TYPE, b)); + } + // ... +} +``` + +### 3. Define the Go struct + +In `go/cpex/types.go` (or a new file): + +```go +const PayloadMyType uint8 = 2 + +type MyPayload struct { + FieldA string `msgpack:"field_a"` + FieldB int64 `msgpack:"field_b"` +} +``` + +### 4. Use it + +```go +result, ct, bg, err := mgr.InvokeByName( + "my_hook", + cpex.PayloadMyType, + MyPayload{FieldA: "hello", FieldB: 42}, + ext, + nil, +) + +// Deserialize modified payload from result +modified, err := cpex.DeserializePayload[MyPayload](result) +``` + +### Total: 5 touch points + +1. Rust struct + `impl_plugin_payload!` +2. FFI constant +3. FFI `deserialize_payload` match arm +4. FFI `serialize_payload` downcast +5. Go struct with msgpack tags + +## 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"`). + +### 1. Define the hook type in Rust + +```rust +pub struct MyHook; +impl HookTypeDef for MyHook { + type Payload = MyPayload; + type Result = PluginResult; + const NAME: &'static str = "my_hook"; +} +``` + +### 2. Write a plugin that handles it + +```rust +impl HookHandler for MyPlugin { + fn handle( + &self, + payload: &MyPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // ... your logic ... + PluginResult::allow() + } +} +``` + +### 3. Create a factory and register it + +```rust +struct MyPluginFactory; +impl PluginFactory for MyPluginFactory { + 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))), + ], + }) + } +} +``` + +### 4. Register in your FFI crate + +```rust +pub fn register_my_factories(manager: &mut PluginManager) { + manager.register_factory("my-plugin-kind", Box::new(MyPluginFactory)); +} +``` + +### 5. Add to YAML config + +```yaml +plugins: + - name: my-plugin + kind: my-plugin-kind + hooks: [my_hook] + mode: sequential + priority: 10 +``` + +### 6. Invoke from Go + +```go +result, ct, bg, err := mgr.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/cmf_plugins.yaml b/examples/go-demo/cmf_plugins.yaml new file mode 100644 index 00000000..53664a36 --- /dev/null +++ b/examples/go-demo/cmf_plugins.yaml @@ -0,0 +1,60 @@ +# Location: ./examples/go-demo/cmf_plugins.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CMF Demo — plugin configuration +# +# Two CMF 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). + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [tool-policy] + pii: + plugins: [tool-policy] + +plugins: + - name: tool-policy + kind: builtin/cmf-tool-policy + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + + - name: header-injector + kind: builtin/cmf-header-injector + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 50 + on_error: ignore + 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 new file mode 100644 index 00000000..1cd1e60c --- /dev/null +++ b/examples/go-demo/ffi/Cargo.toml @@ -0,0 +1,27 @@ +# Location: ./examples/go-demo/ffi/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Demo FFI — demo plugins for Go example. +# +# Builds a staticlib that includes cpex-ffi symbols transitively. +# Go links only this library to get both the core FFI surface and +# the demo plugin factories. + +[package] +name = "cpex-demo-ffi" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +cpex-core = { path = "../../../crates/cpex-core" } +cpex-ffi = { path = "../../../crates/cpex-ffi" } +async-trait = "0.1" +serde_json = "1" +tracing = "0.1" diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs new file mode 100644 index 00000000..f59d9e84 --- /dev/null +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -0,0 +1,274 @@ +// Location: ./examples/go-demo/ffi/src/cmf_plugins.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF demo plugins — operate on MessagePayload (typed CMF messages). +// +// Two plugins demonstrating typed message inspection and +// capability-gated extension modification: +// +// - ToolPolicyPlugin: extracts tool calls from the CMF message, +// checks permissions against meta tags and security labels. +// PII-tagged tools require a "PII" label in the security +// extension; admin-tagged tools require an "admin" role. +// +// - HeaderInjectorPlugin: inspects tool calls/results and injects +// response headers (X-Tool-Name, X-Tool-Status, X-CPEX-Processed) +// using the capability-gated Guarded write pattern. +// Requires "write_headers" capability in the plugin config. + +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"; +} + +// --------------------------------------------------------------------------- +// Tool Policy Plugin +// --------------------------------------------------------------------------- + +/// Checks tool call permissions against security labels and meta tags. +/// +/// Policy rules: +/// - Tools tagged "pii" require security label "PII" in extensions +/// - Tools tagged "admin" require subject role "admin" +/// - All tool calls are logged with their arguments +struct ToolPolicyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for ToolPolicyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for ToolPolicyPlugin { + fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Extract tool calls from the CMF message + let tool_calls: Vec<_> = payload + .message + .content + .iter() + .filter_map(|cp| match cp { + ContentPart::ToolCall { content } => Some(content), + _ => None, + }) + .collect(); + + if tool_calls.is_empty() { + return PluginResult::allow(); + } + + // Check meta tags for PII requirement + let has_pii_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "pii")) + .unwrap_or(false); + + // Check security labels + let has_pii_label = extensions + .security + .as_ref() + .map(|s| s.labels.contains(&"PII".to_string())) + .unwrap_or(false); + + // If PII tagged but no PII label in security context — deny + if has_pii_tag && !has_pii_label { + let tool_name = tool_calls + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + tracing::warn!( + "[tool-policy] DENIED: tool '{}' requires PII label but caller lacks it", + tool_name + ); + return PluginResult::deny(PluginViolation::new( + "pii_label_required", + format!( + "Tool '{}' is PII-tagged but security context lacks PII label", + tool_name + ), + )); + } + + // Check admin requirement + let has_admin_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "admin")) + .unwrap_or(false); + + if has_admin_tag { + let has_admin_role = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .map(|subj| subj.roles.iter().any(|r| r == "admin")) + .unwrap_or(false); + + if !has_admin_role { + return PluginResult::deny(PluginViolation::new( + "admin_required", + "This tool requires admin role", + )); + } + } + + for tc in &tool_calls { + tracing::info!( + "[tool-policy] OK: tool '{}' (call_id={}) authorized", + tc.name, + tc.tool_call_id, + ); + } + + PluginResult::allow() + } +} + +pub struct ToolPolicyFactory; + +impl PluginFactory for ToolPolicyFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(ToolPolicyPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Header Injector Plugin +// --------------------------------------------------------------------------- + +/// Adds response headers after tool execution. +/// +/// Inspects the CMF message (tool results) and adds: +/// - X-Tool-Name: name of the tool that ran +/// - X-Tool-Status: "success" or "error" +/// - X-CPEX-Processed: "true" +struct HeaderInjectorPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for HeaderInjectorPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for HeaderInjectorPlugin { + fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Look for tool results or tool calls + let tool_name = payload.message.content.iter().find_map(|cp| match cp { + ContentPart::ToolResult { content } => Some(content.tool_name.as_str()), + ContentPart::ToolCall { content } => Some(content.name.as_str()), + _ => None, + }); + + let is_error = payload.message.content.iter().any(|cp| { + matches!( + cp, + ContentPart::ToolResult { content } if content.is_error + ) + }); + + if let Some(name) = tool_name { + // COW copy — clones mutable slots, propagates write tokens + 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"); + } + } + + return PluginResult::modify_extensions(owned); + } + + PluginResult::allow() + } +} + +pub struct HeaderInjectorFactory; + +impl PluginFactory for HeaderInjectorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(HeaderInjectorPlugin { + 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)), + ), + ], + }) + } +} + +/// Register CMF demo plugin factories on a manager. +pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); + manager.register_factory( + "builtin/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 new file mode 100644 index 00000000..84351929 --- /dev/null +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -0,0 +1,275 @@ +// Location: ./examples/go-demo/ffi/src/demo_plugins.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Generic demo plugins for the Go example. +// +// Three plugins that operate on GenericPayload (serde_json::Value), +// demonstrating identity validation, PII policy enforcement, and +// audit logging through the CPEX plugin pipeline: +// +// - IdentityChecker: validates that a "user" field is present in +// the payload or a subject ID exists in security extensions +// - PiiGuard: blocks access to PII-tagged tools unless the payload +// contains a "pii_clearance" flag +// - AuditLogger: logs tool invocations with entity type, tool name, +// and user (fire-and-forget mode) + +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 cpex_ffi::GenericPayload; + +// --------------------------------------------------------------------------- +// Generic Hook Type +// --------------------------------------------------------------------------- + +/// 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. +pub struct GenericHook; + +impl HookTypeDef for GenericHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "generic"; +} + +// --------------------------------------------------------------------------- +// Identity Checker +// --------------------------------------------------------------------------- + +struct IdentityChecker { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityChecker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for IdentityChecker { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let user = payload.value.get("user").and_then(|v| v.as_str()); + + let subject_id = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.as_deref()); + + match user.or(subject_id) { + Some(u) if !u.is_empty() => { + tracing::info!("[identity-checker] OK: user '{}' identified", u); + PluginResult::allow() + } + _ => { + tracing::warn!("[identity-checker] DENIED: no user identity"); + PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )) + } + } + } +} + +pub 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![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// PII Guard +// --------------------------------------------------------------------------- + +struct PiiGuard { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for PiiGuard { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for PiiGuard { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let has_pii_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "pii")) + .unwrap_or(false); + + let has_pii_label = extensions + .security + .as_ref() + .map(|s| s.labels.contains(&"PII".to_string())) + .unwrap_or(false); + + if !has_pii_tag && !has_pii_label { + return PluginResult::allow(); + } + + let has_clearance = payload + .value + .get("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if has_clearance { + tracing::info!("[pii-guard] OK: PII clearance verified"); + PluginResult::allow() + } else { + let tool_name = payload + .value + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::warn!( + "[pii-guard] DENIED: PII clearance required for '{}'", + tool_name + ); + PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required for this operation", + )) + } + } +} + +pub 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)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Audit Logger +// --------------------------------------------------------------------------- + +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let tool_name = payload + .value + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let user = payload + .value + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("anonymous"); + let entity_type = extensions + .meta + .as_ref() + .and_then(|m| m.entity_type.as_deref()) + .unwrap_or("unknown"); + + tracing::info!( + "[audit-logger] LOG: entity_type={} tool={} user={}", + entity_type, + tool_name, + user, + ); + PluginResult::allow() + } +} + +pub 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)), + ), + ], + }) + } +} + +/// Register all demo plugin factories on a manager. +pub fn register_demo_factories(manager: &mut 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)); +} diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs new file mode 100644 index 00000000..8f756f3a --- /dev/null +++ b/examples/go-demo/ffi/src/lib.rs @@ -0,0 +1,56 @@ +// Location: ./examples/go-demo/ffi/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Demo FFI — re-exports cpex-ffi and adds demo plugin factories. +// +// This crate builds a staticlib that includes all cpex-ffi symbols +// transitively. Go links only this library — no need to link +// libcpex_ffi separately. +// +// Exports one C function: `cpex_demo_register_factories()` which +// registers both generic and CMF demo plugin factories: +// +// Generic (GenericPayload): +// - `builtin/identity` — identity checker +// - `builtin/pii` — PII guard +// - `builtin/audit` — audit logger +// +// CMF (MessagePayload): +// - `builtin/cmf-tool-policy` — tool permission checking +// - `builtin/cmf-header-injector` — response header injection + +mod cmf_plugins; +mod demo_plugins; + +// Force the linker to include all cpex-ffi symbols in our staticlib. +// Without this, the extern "C" functions from cpex-ffi would be +// stripped as "unused" since we don't call them from Rust. +extern crate cpex_ffi; + +use std::os::raw::c_int; + +/// Register demo plugin factories on the manager. +/// +/// 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 +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new_default`. +#[no_mangle] +pub unsafe extern "C" fn cpex_demo_register_factories( + mgr: *mut cpex_ffi::CpexManagerInner, +) -> c_int { + let inner = match mgr.as_mut() { + Some(m) => m, + None => return -1, + }; + + demo_plugins::register_demo_factories(&mut inner.manager); + cmf_plugins::register_cmf_factories(&mut inner.manager); + 0 +} diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod new file mode 100644 index 00000000..4e5bff08 --- /dev/null +++ b/examples/go-demo/go.mod @@ -0,0 +1,12 @@ +module github.com/contextforge-org/contextforge-plugins-framework/examples/go-demo + +go 1.25.4 + +require github.com/contextforge-org/contextforge-plugins-framework/go/cpex v0.0.0 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) + +replace github.com/contextforge-org/contextforge-plugins-framework/go/cpex => ../../go/cpex diff --git a/examples/go-demo/go.sum b/examples/go-demo/go.sum new file mode 100644 index 00000000..fd15c1b8 --- /dev/null +++ b/examples/go-demo/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go-demo/main.go b/examples/go-demo/main.go new file mode 100644 index 00000000..33aecc16 --- /dev/null +++ b/examples/go-demo/main.go @@ -0,0 +1,242 @@ +// Location: ./examples/go-demo/main.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Go Demo — generic payload example. +// +// Demonstrates the full CPEX plugin pipeline from Go using +// GenericPayload (untyped map payloads): +// +// 1. Create a PluginManager via the Go SDK +// 2. Register demo plugin factories (identity, PII, audit) +// 3. Load YAML config with routing rules and policy groups +// 4. Invoke hooks with MetaExtension for route resolution +// 5. Inspect results (allow/deny, violations) +// 6. Thread ContextTable between pre-invoke and post-invoke +// +// Build & run: +// +// cd examples/go-demo/ffi && cargo build --release +// cd examples/go-demo && go run main.go + +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_demo_ffi -lm -ldl -lpthread -framework CoreFoundation -framework Security +#include + +int cpex_demo_register_factories(void* mgr); +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +func main() { + fmt.Println("=== CPEX Go Demo ===") + fmt.Println() + + // --- Create manager --- + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + defer mgr.Shutdown() + + // --- Register demo factories via callback --- + err = mgr.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) + } + return nil + }) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + // --- Load YAML config --- + yaml, err := os.ReadFile("plugins.yaml") + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + if err := mgr.LoadConfig(string(yaml)); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + // --- Initialize --- + if err := mgr.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"), + ) + + // ----------------------------------------------------------------------- + // Scenario 1: PII tool WITHOUT clearance — should be DENIED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 1: get_compensation (no PII clearance) ===") + fmt.Println() + + result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + "arguments": "employee_id=42", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 2: PII tool WITH clearance — should be ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 2: get_compensation (with PII clearance) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + "arguments": "employee_id=42", + "pii_clearance": true, + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + + // Thread context table into post-invoke + fmt.Println(" --- post-invoke for get_compensation ---") + fmt.Println() + + result2, ct2, bg2, err := mgr.InvokeByName("tool_post_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + ct, // thread context table from pre-invoke + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result2) + bg2.Close() + ct2.Close() + + // ----------------------------------------------------------------------- + // Scenario 3: Non-PII tool — should be ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 3: list_departments (non-PII tool) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "list_departments", + "user": "bob", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 4: No user identity — should be DENIED by identity-checker + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 4: list_departments (no user identity) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "list_departments", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + fmt.Println("=== Demo complete ===") +} + +func printResult(result *cpex.PipelineResult) { + if !result.IsDenied() { + fmt.Printf(" Result: ALLOWED\n\n") + } else { + v := result.Violation + fmt.Printf(" Result: DENIED — %s [%s]\n\n", v.Reason, v.Code) + } +} diff --git a/examples/go-demo/plugins.yaml b/examples/go-demo/plugins.yaml new file mode 100644 index 00000000..f7975cbb --- /dev/null +++ b/examples/go-demo/plugins.yaml @@ -0,0 +1,59 @@ +# Location: ./examples/go-demo/plugins.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# 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) +# - Audit logging on all tools (fire-and-forget) + +plugin_settings: + routing_enabled: true + 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] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + 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: + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + - tool: list_departments + plugins: + - audit-logger + + - tool: "*" + plugins: + - audit-logger diff --git a/go/cpex/README.md b/go/cpex/README.md new file mode 100644 index 00000000..220eef68 --- /dev/null +++ b/go/cpex/README.md @@ -0,0 +1,367 @@ +# 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. + +## Prerequisites + +- **Go 1.21+** +- **Rust toolchain** (stable, 1.75+) +- **Built Rust library**: the Go SDK links against `libcpex_ffi.a` + +```bash +# From the repository root +cargo build --release -p cpex-ffi +``` + +## Package Structure + +``` +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) +``` + +## Quick Start + +```go +import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" + +// 1. Create a manager +mgr, err := cpex.NewPluginManagerDefault() +defer mgr.Shutdown() + +// 2. Register plugin factories (Rust-side, via callback) +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + C.my_register_factories(handle) + return nil +}) + +// 3. Load YAML config +mgr.LoadConfig(yamlString) + +// 4. Initialize plugins +mgr.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"}, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + }, + nil, // context table (nil for first call) +) +defer ct.Close() +defer bg.Close() + +if result.IsDenied() { + fmt.Printf("Denied: %s\n", result.Violation.Reason) +} +``` + +## Lifecycle + +``` +NewPluginManagerDefault() + → RegisterFactories(fn) // register Rust plugin factories + → LoadConfig(yaml) // parse YAML, instantiate plugins + → Initialize() // call plugin.initialize() on all + → InvokeByName(...) // invoke hooks, get results + → Shutdown() // call plugin.shutdown(), free resources +``` + +## Payload Types + +| Constant | Value | Description | +|----------------------|-------|----------------------------------| +| `PayloadGeneric` | 0 | Generic map payload (`map[string]any`) | +| `PayloadCMFMessage` | 1 | Typed CMF `MessagePayload` | + +Use `PayloadGeneric` for simple key-value payloads. Use `PayloadCMFMessage` when sending structured CMF messages with typed content parts (tool calls, resources, media, etc.). + +## CMF Content Types + +The `ContentPart` tagged union supports all 12 content types: + +| Type | Constructor | Content Field | +|------|-------------|---------------| +| `text` | `NewTextPart("hello")` | `Text` | +| `thinking` | `NewThinkingPart("...")` | `Text` | +| `tool_call` | `NewToolCallPart(tc)` | `ToolCallContent` | +| `tool_result` | `NewToolResultPart(tr)` | `ToolResultContent` | +| `resource` | `NewResourcePart(r)` | `ResourceContent` | +| `resource_ref` | `NewResourceRefPart(r)` | `ResourceRefContent` | +| `prompt_request` | `NewPromptRequestPart(pr)` | `PromptRequestContent` | +| `prompt_result` | `NewPromptResultPart(pr)` | `PromptResultContent` | +| `image` | `NewImagePart(img)` | `ImageContent` | +| `video` | `NewVideoPart(vid)` | `VideoContent` | +| `audio` | `NewAudioPart(aud)` | `AudioContent` | +| `document` | `NewDocumentPart(doc)` | `DocumentContent` | + +## Extensions + +Extensions are passed separately from the payload. Each extension type maps to a Rust extension in `crates/cpex-core/src/extensions/`: + +- `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 + +## 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", ...) +bg1.Close() + +// Thread context table into post-invoke +result2, ct2, bg2, _ := mgr.InvokeByName("tool_post_invoke", ..., ct1) +bg2.Close() +ct2.Close() +``` + +## Writing Plugins (Rust) for Go Callers + +Plugins are written in Rust and compiled into a separate FFI crate that the Go program links. This keeps the core `cpex-ffi` library clean while allowing each project to bring its own plugins. + +### 1. Create a Rust FFI crate + +``` +my-project/ + plugins-ffi/ + Cargo.toml + src/ + lib.rs + my_plugin.rs +``` + +**`Cargo.toml`**: + +```toml +[package] +name = "my-plugins-ffi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +cpex-core = { path = "path/to/crates/cpex-core" } +cpex-ffi = { path = "path/to/crates/cpex-ffi" } +async-trait = "0.1" +tracing = "0.1" +``` + +### 2. Define your hook type and plugin + +**`src/my_plugin.rs`**: + +```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 cpex_ffi::GenericPayload; + +// Hook type — the NAME can be any string; Go callers use this name +pub struct MyHook; +impl HookTypeDef for MyHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "my_hook"; +} + +// Plugin implementation +struct RateLimiter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RateLimiter { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for RateLimiter { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Your plugin logic here + PluginResult::allow() + } +} + +// Factory — creates plugin instances from YAML config +pub struct RateLimiterFactory; +impl PluginFactory for RateLimiterFactory { + 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( + TypedHandlerAdapter::::new(plugin), + )), + ], + }) + } +} + +pub fn register_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("my/rate-limiter", Box::new(RateLimiterFactory)); +} +``` + +### 3. Export the C registration function + +**`src/lib.rs`**: + +```rust +mod my_plugin; + +// Include cpex-ffi symbols in this staticlib +extern crate cpex_ffi; + +use std::os::raw::c_int; + +#[no_mangle] +pub unsafe extern "C" fn my_register_factories( + mgr: *mut cpex_ffi::CpexManagerInner, +) -> c_int { + let inner = match mgr.as_mut() { + Some(m) => m, + None => return -1, + }; + my_plugin::register_factories(&mut inner.manager); + 0 +} +``` + +### 4. Call from Go + +```go +/* +#cgo LDFLAGS: -L/path/to/target/release -lmy_plugins_ffi -lm -ldl -lpthread +int my_register_factories(void* mgr); +*/ +import "C" + +mgr, _ := cpex.NewPluginManagerDefault() + +mgr.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() + +result, ct, bg, _ := mgr.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 +- The YAML `kind` field must match what you pass to `register_factory()` + +## Adding a New Payload Type + +The payload type registry maps a `uint8` discriminator to a Rust type for FFI deserialization. To add a new one: + +### Rust side (3 files) + +**1. Define the type** (in `cpex-core` or your own crate): + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MyPayload { + pub field_a: String, + pub field_b: i64, +} +cpex_core::impl_plugin_payload!(MyPayload); +``` + +**2. Register in FFI** (`crates/cpex-ffi/src/lib.rs`): + +```rust +pub const PAYLOAD_MY_TYPE: u8 = 2; + +// In deserialize_payload(): +PAYLOAD_MY_TYPE => { + let v: MyPayload = rmp_serde::from_slice(bytes)?; + Ok(Box::new(v)) +} + +// In serialize_payload(): +if let Some(mp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(mp).ok().map(|b| (PAYLOAD_MY_TYPE, b)); +} +``` + +### Go side (1 file) + +**3. Define the Go struct** (`go/cpex/types.go` or a new file): + +```go +const PayloadMyType uint8 = 2 + +type MyPayload struct { + FieldA string `msgpack:"field_a"` + FieldB int64 `msgpack:"field_b"` +} +``` + +### Use it + +```go +result, ct, bg, _ := mgr.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. + +## Tests + +```bash +# Build the Rust library first +cargo build --release -p cpex-ffi + +# Run Go tests +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 diff --git a/go/cpex/cmf.go b/go/cpex/cmf.go new file mode 100644 index 00000000..cfe82ead --- /dev/null +++ b/go/cpex/cmf.go @@ -0,0 +1,390 @@ +// Location: ./go/cpex/cmf.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF (ContextForge Message Format) types for Go. +// +// Mirrors the Rust types in crates/cpex-core/src/cmf/. The Message +// struct carries typed content parts (text, tool calls, resources, +// media, etc.) without extensions — those are passed separately. +// +// ContentPart is a tagged union discriminated by the "content_type" +// field. Custom msgpack Encoder/Decoder methods produce the same +// wire format as Rust's #[serde(tag = "content_type")] enum. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// --------------------------------------------------------------------------- +// CMF Message Types +// --------------------------------------------------------------------------- + +// MessagePayload wraps a Message for FFI transport. +// Matches Rust's cpex_core::cmf::MessagePayload. +type MessagePayload struct { + Message Message `msgpack:"message"` +} + +// 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"` +} + +// NewMessage creates a Message with the default schema version. +func NewMessage(role string, content ...ContentPart) Message { + return Message{ + SchemaVersion: "2.0", + Role: role, + Content: content, + } +} + +// --------------------------------------------------------------------------- +// Content Parts — tagged union via content_type discriminator +// --------------------------------------------------------------------------- + +// ContentPart represents one element in a Message's content list. +// Uses custom msgpack marshaling to produce the tagged-union wire format: +// +// {"content_type": "text", "text": "hello"} +// {"content_type": "tool_call", "content": {...}} +// +// The ContentType field determines which content field is populated. +// Text and Thinking use the Text field directly; all other types use +// their respective content field. +type ContentPart struct { + ContentType string + + // Text/Thinking — "text" field at top level + Text string + + // Structured content — "content" field wrapping a domain object. + // Only one is set based on ContentType. + ToolCallContent *ToolCall + ToolResultContent *ToolResult + ResourceContent *Resource + ResourceRefContent *ResourceReference + PromptRequestContent *PromptRequest + PromptResultContent *PromptResult + ImageContent *ImageSource + VideoContent *VideoSource + AudioContent *AudioSource + DocumentContent *DocumentSource + + // rawMap captures the full original wire form for content_type + // values this Go SDK doesn't have a typed accessor for. Lets a + // newer Rust runtime emitting a future variant pass through + // older Go bindings without losing data on round-trip — Encode + // emits rawMap verbatim when ContentType isn't a known case. + // Private because users with an unknown ContentType have no + // safe way to interpret it; they can only forward it. + rawMap map[string]any +} + +// EncodeMsgpack produces the tagged-union wire format. +func (cp ContentPart) EncodeMsgpack(enc *msgpack.Encoder) error { + // Helper: a body envelope wrapping a typed `content` value. + body := func(content any) map[string]any { + return map[string]any{ + wireKeyContentType: cp.ContentType, + wireKeyContent: content, + } + } + + switch cp.ContentType { + case ContentTypeText, ContentTypeThinking: + return enc.Encode(map[string]any{ + wireKeyContentType: cp.ContentType, + wireKeyText: cp.Text, + }) + case ContentTypeToolCall: + return enc.Encode(body(cp.ToolCallContent)) + case ContentTypeToolResult: + return enc.Encode(body(cp.ToolResultContent)) + case ContentTypeResource: + return enc.Encode(body(cp.ResourceContent)) + case ContentTypeResourceRef: + return enc.Encode(body(cp.ResourceRefContent)) + case ContentTypePromptRequest: + return enc.Encode(body(cp.PromptRequestContent)) + case ContentTypePromptResult: + return enc.Encode(body(cp.PromptResultContent)) + case ContentTypeImage: + return enc.Encode(body(cp.ImageContent)) + case ContentTypeVideo: + return enc.Encode(body(cp.VideoContent)) + case ContentTypeAudio: + return enc.Encode(body(cp.AudioContent)) + case ContentTypeDocument: + return enc.Encode(body(cp.DocumentContent)) + default: + // Unknown content_type. If we captured the raw wire form on + // decode (forward-compat path), emit it verbatim so we don't + // lose data on round-trip. Otherwise fall back to a minimal + // content_type-only message (a Go-side construction with an + // unrecognized ContentType — rare). + if cp.rawMap != nil { + return enc.Encode(cp.rawMap) + } + out := map[string]any{wireKeyContentType: cp.ContentType} + if cp.Text != "" { + out[wireKeyText] = cp.Text + } + return enc.Encode(out) + } +} + +// DecodeMsgpack reads the tagged-union wire format. +func (cp *ContentPart) DecodeMsgpack(dec *msgpack.Decoder) error { + var raw map[string]any + if err := dec.Decode(&raw); err != nil { + return err + } + + if ct, ok := raw[wireKeyContentType].(string); ok { + cp.ContentType = ct + } + + switch cp.ContentType { + case ContentTypeText, ContentTypeThinking: + if t, ok := raw[wireKeyText].(string); ok { + cp.Text = t + } + case ContentTypeToolCall: + cp.ToolCallContent = decodeAs[ToolCall](raw[wireKeyContent]) + case ContentTypeToolResult: + cp.ToolResultContent = decodeAs[ToolResult](raw[wireKeyContent]) + case ContentTypeResource: + cp.ResourceContent = decodeAs[Resource](raw[wireKeyContent]) + case ContentTypeResourceRef: + cp.ResourceRefContent = decodeAs[ResourceReference](raw[wireKeyContent]) + case ContentTypePromptRequest: + cp.PromptRequestContent = decodeAs[PromptRequest](raw[wireKeyContent]) + case ContentTypePromptResult: + cp.PromptResultContent = decodeAs[PromptResult](raw[wireKeyContent]) + case ContentTypeImage: + cp.ImageContent = decodeAs[ImageSource](raw[wireKeyContent]) + case ContentTypeVideo: + cp.VideoContent = decodeAs[VideoSource](raw[wireKeyContent]) + case ContentTypeAudio: + cp.AudioContent = decodeAs[AudioSource](raw[wireKeyContent]) + case ContentTypeDocument: + cp.DocumentContent = decodeAs[DocumentSource](raw[wireKeyContent]) + default: + // Unknown content_type — preserve the full wire form so + // EncodeMsgpack can pass it through unchanged. Forward + // compat for newer Rust variants the Go SDK doesn't know + // about yet (P2 #17). + cp.rawMap = raw + } + + return nil +} + +// --------------------------------------------------------------------------- +// Content Part Constructors +// --------------------------------------------------------------------------- + +// Constructor functions are named `NewXPart` to avoid shadowing the +// matching `XContent` field on ContentPart. Previously a constructor +// like `ToolCallContent(tc)` had the same name as the field +// `cp.ToolCallContent` — confusing in code and hostile to IDE +// autocomplete. The `New*Part` form mirrors common Go conventions +// (`NewClient`, `NewBuffer`). + +// NewTextPart creates a text content part. +func NewTextPart(text string) ContentPart { + return ContentPart{ContentType: ContentTypeText, Text: text} +} + +// NewThinkingPart creates a thinking content part. +func NewThinkingPart(text string) ContentPart { + return ContentPart{ContentType: ContentTypeThinking, Text: text} +} + +// NewToolCallPart creates a tool_call content part. +func NewToolCallPart(tc ToolCall) ContentPart { + return ContentPart{ContentType: ContentTypeToolCall, ToolCallContent: &tc} +} + +// NewToolResultPart creates a tool_result content part. +func NewToolResultPart(tr ToolResult) ContentPart { + return ContentPart{ContentType: ContentTypeToolResult, ToolResultContent: &tr} +} + +// NewResourcePart creates a resource content part. +func NewResourcePart(r Resource) ContentPart { + return ContentPart{ContentType: ContentTypeResource, ResourceContent: &r} +} + +// NewResourceRefPart creates a resource_ref content part. +func NewResourceRefPart(r ResourceReference) ContentPart { + return ContentPart{ContentType: ContentTypeResourceRef, ResourceRefContent: &r} +} + +// NewPromptRequestPart creates a prompt_request content part. +func NewPromptRequestPart(pr PromptRequest) ContentPart { + return ContentPart{ContentType: ContentTypePromptRequest, PromptRequestContent: &pr} +} + +// NewPromptResultPart creates a prompt_result content part. +func NewPromptResultPart(pr PromptResult) ContentPart { + return ContentPart{ContentType: ContentTypePromptResult, PromptResultContent: &pr} +} + +// NewImagePart creates an image content part. +func NewImagePart(img ImageSource) ContentPart { + return ContentPart{ContentType: ContentTypeImage, ImageContent: &img} +} + +// NewVideoPart creates a video content part. +func NewVideoPart(vid VideoSource) ContentPart { + return ContentPart{ContentType: ContentTypeVideo, VideoContent: &vid} +} + +// NewAudioPart creates an audio content part. +func NewAudioPart(aud AudioSource) ContentPart { + return ContentPart{ContentType: ContentTypeAudio, AudioContent: &aud} +} + +// NewDocumentPart creates a document content part. +func NewDocumentPart(doc DocumentSource) ContentPart { + return ContentPart{ContentType: ContentTypeDocument, DocumentContent: &doc} +} + +// --------------------------------------------------------------------------- +// Domain Objects +// --------------------------------------------------------------------------- + +// ToolCall represents a tool invocation request. +type ToolCall struct { + ToolCallID string `msgpack:"tool_call_id"` + Name string `msgpack:"name"` + Arguments map[string]any `msgpack:"arguments,omitempty"` + Namespace string `msgpack:"namespace,omitempty"` +} + +// ToolResult represents the output of a tool execution. +type ToolResult struct { + ToolCallID string `msgpack:"tool_call_id"` + ToolName string `msgpack:"tool_name"` + Content any `msgpack:"content,omitempty"` + IsError bool `msgpack:"is_error,omitempty"` +} + +// Resource represents an embedded resource with content (MCP). +type Resource struct { + ResourceRequestID string `msgpack:"resource_request_id"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + Description string `msgpack:"description,omitempty"` + ResourceType string `msgpack:"resource_type"` + Content string `msgpack:"content,omitempty"` + Blob []byte `msgpack:"blob,omitempty"` + MimeType string `msgpack:"mime_type,omitempty"` + SizeBytes *uint64 `msgpack:"size_bytes,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` + Version string `msgpack:"version,omitempty"` +} + +// ResourceReference is a lightweight resource reference without content. +type ResourceReference struct { + ResourceRequestID string `msgpack:"resource_request_id"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + ResourceType string `msgpack:"resource_type"` + RangeStart *uint64 `msgpack:"range_start,omitempty"` + RangeEnd *uint64 `msgpack:"range_end,omitempty"` + Selector string `msgpack:"selector,omitempty"` +} + +// PromptRequest represents a prompt template invocation request (MCP). +type PromptRequest struct { + PromptRequestID string `msgpack:"prompt_request_id"` + Name string `msgpack:"name"` + Arguments map[string]any `msgpack:"arguments,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// PromptResult represents a rendered prompt template result. +type PromptResult struct { + PromptRequestID string `msgpack:"prompt_request_id"` + PromptName string `msgpack:"prompt_name"` + Messages []Message `msgpack:"messages,omitempty"` + Content string `msgpack:"content,omitempty"` + IsError bool `msgpack:"is_error,omitempty"` + ErrorMessage string `msgpack:"error_message,omitempty"` +} + +// --------------------------------------------------------------------------- +// Media Source Types +// --------------------------------------------------------------------------- + +// ImageSource holds image data (URL or base64). +type ImageSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` +} + +// VideoSource holds video data (URL or base64). +type VideoSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + DurationMs *uint64 `msgpack:"duration_ms,omitempty"` +} + +// AudioSource holds audio data (URL or base64). +type AudioSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + DurationMs *uint64 `msgpack:"duration_ms,omitempty"` +} + +// DocumentSource holds document data (URL or base64). +type DocumentSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + Title string `msgpack:"title,omitempty"` +} + +// --------------------------------------------------------------------------- +// Decode helpers — extract typed domain objects from a decoded `any` value. +// --------------------------------------------------------------------------- + +// 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 +// manually — that pattern was the source of the silent data loss +// reviewer flagged in #13 (`DurationMs`, `RangeStart/End`, `Blob`, +// `SizeBytes`, `Messages` were all dropped). Adding a new field to a +// struct now Just Works without a corresponding decoder edit. +// +// Cost: an extra msgpack marshal + unmarshal per content part. This is +// on the per-message decode path, not per-pipeline-step. msgpack is +// fast; in practice it's microseconds. If this ever shows up on a hot +// path we can switch to msgpack's `Decoder.Query()` or hand-roll +// targeted decoders for specific high-volume types. +func decodeAs[T any](v any) *T { + if v == nil { + return nil + } + bytes, err := msgpack.Marshal(v) + if err != nil { + return nil + } + var out T + if err := msgpack.Unmarshal(bytes, &out); err != nil { + return nil + } + return &out +} diff --git a/go/cpex/cmf_test.go b/go/cpex/cmf_test.go new file mode 100644 index 00000000..e329aabe --- /dev/null +++ b/go/cpex/cmf_test.go @@ -0,0 +1,262 @@ +// Location: ./go/cpex/cmf_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MessagePack roundtrip coverage for the CMF tagged-union ContentPart. +// +// Pass 4 of the CGO review fixed an audit's worth of dropped fields +// across the per-variant decoders (DurationMs, RangeStart/End, Blob, +// SizeBytes, Messages, etc.) and rewrote them on top of a generic +// `decodeAs[T]` helper. These tests pin that fix: every variant must +// roundtrip msgpack with all its fields intact, and unknown variants +// must passthrough via the rawMap path. + +package cpex + +import ( + "reflect" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +// roundTripContentPart encodes via ContentPart.EncodeMsgpack and +// decodes via ContentPart.DecodeMsgpack — the same path the FFI +// uses on either side of the boundary. Returns the decoded value +// for the caller to deep-compare. +func roundTripContentPart(t *testing.T, original ContentPart) ContentPart { + t.Helper() + bytes, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(bytes, &decoded); err != nil { + t.Fatalf("decode failed: %v", err) + } + return decoded +} + +func u64ptr(v uint64) *uint64 { return &v } + +// Each subtest below builds a fully-populated variant — every field +// present, including the ones Pass 4's review found dropped — and +// asserts roundtrip equality via reflect.DeepEqual. A missing field +// in either the encoder or decoder produces a diff and a clean +// failure pointing at the variant. + +func TestContentPart_RoundTripText(t *testing.T) { + original := NewTextPart("hello world") + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("text roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripThinking(t *testing.T) { + original := NewThinkingPart("internal monologue") + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("thinking roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripToolCall(t *testing.T) { + original := NewToolCallPart(ToolCall{ + ToolCallID: "call-1", + Name: "search", + Arguments: map[string]any{"q": "anthropic", "limit": int64(10)}, + Namespace: "tools.web", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("tool_call roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripToolResult(t *testing.T) { + original := NewToolResultPart(ToolResult{ + ToolCallID: "call-1", + ToolName: "search", + Content: "result body", + IsError: false, + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("tool_result roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripResource(t *testing.T) { + // All Pass 4-restored fields populated: Blob, SizeBytes, plus the + // existing ones. If decodeAs[Resource] drops any of these the + // reflect.DeepEqual catches it. + original := NewResourcePart(Resource{ + ResourceRequestID: "req-1", + URI: "file://x", + Name: "x.txt", + Description: "a file", + ResourceType: "text", + Content: "body", + Blob: []byte{0x01, 0x02, 0x03}, + MimeType: "text/plain", + SizeBytes: u64ptr(3), + Annotations: map[string]any{"tag": "v1"}, + Version: "v1", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("resource roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripResourceRef(t *testing.T) { + // RangeStart/RangeEnd were both dropped pre-Pass 4 — explicit fields here. + original := NewResourceRefPart(ResourceReference{ + ResourceRequestID: "req-2", + URI: "file://y", + Name: "y.txt", + ResourceType: "text", + RangeStart: u64ptr(0), + RangeEnd: u64ptr(100), + Selector: "$.body", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("resource_ref roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripPromptRequest(t *testing.T) { + original := NewPromptRequestPart(PromptRequest{ + PromptRequestID: "pr-1", + Name: "summarize", + Arguments: map[string]any{"length": "short"}, + ServerID: "srv-1", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("prompt_request roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripPromptResult(t *testing.T) { + // Messages was dropped entirely pre-Pass 4. Populating it here + // pins the fix. + original := NewPromptResultPart(PromptResult{ + PromptRequestID: "pr-1", + PromptName: "summarize", + Messages: []Message{ + NewMessage("user", NewTextPart("input")), + NewMessage("assistant", NewTextPart("output")), + }, + Content: "summary text", + IsError: false, + ErrorMessage: "", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("prompt_result roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripImage(t *testing.T) { + original := NewImagePart(ImageSource{ + SourceType: "base64", + Data: "aW1hZ2U=", + MediaType: "image/png", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("image roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripVideo(t *testing.T) { + // DurationMs was dropped pre-Pass 4 — explicit field here. + original := NewVideoPart(VideoSource{ + SourceType: "url", + Data: "https://example/v.mp4", + MediaType: "video/mp4", + DurationMs: u64ptr(15000), + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("video roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripAudio(t *testing.T) { + // Same DurationMs drop — explicit field. + original := NewAudioPart(AudioSource{ + SourceType: "base64", + Data: "YXVkaW8=", + MediaType: "audio/mp3", + DurationMs: u64ptr(5500), + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("audio roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripDocument(t *testing.T) { + original := NewDocumentPart(DocumentSource{ + SourceType: "url", + Data: "https://example/d.pdf", + MediaType: "application/pdf", + Title: "doc", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("document roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +// Forward-compat: a content_type the Go decoder doesn't recognize +// must passthrough via rawMap so that re-encoding produces the same +// wire bytes. This protects against silent drops when Rust adds a +// variant that Go hasn't been updated for yet. +func TestContentPart_UnknownVariantPassesThrough(t *testing.T) { + // Build a payload by encoding a known structure with an + // unrecognized content_type tag — simulate Rust shipping a future + // variant. + wireMap := map[string]any{ + "content_type": "future_variant_v2", + "content": map[string]any{ + "foo": "bar", + "n": int64(42), + }, + } + originalBytes, err := msgpack.Marshal(wireMap) + if err != nil { + t.Fatalf("encode wire fixture failed: %v", err) + } + + var cp ContentPart + if err := msgpack.Unmarshal(originalBytes, &cp); err != nil { + t.Fatalf("decode unknown variant failed: %v", err) + } + + // Re-encode and compare to the original wire bytes. The tag and + // the body must both survive intact — that's what rawMap is for. + roundTripBytes, err := msgpack.Marshal(cp) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + + // Decode both sides into generic maps to compare semantically + // (msgpack key ordering is not guaranteed across encode passes). + var originalMap, roundTripMap map[string]any + if err := msgpack.Unmarshal(originalBytes, &originalMap); err != nil { + t.Fatalf("decode original to map: %v", err) + } + if err := msgpack.Unmarshal(roundTripBytes, &roundTripMap); err != nil { + t.Fatalf("decode roundtrip to map: %v", err) + } + if !reflect.DeepEqual(originalMap, roundTripMap) { + t.Errorf("unknown variant passthrough mismatch:\n want: %+v\n got: %+v", + originalMap, roundTripMap) + } +} diff --git a/go/cpex/constants.go b/go/cpex/constants.go new file mode 100644 index 00000000..45ce3858 --- /dev/null +++ b/go/cpex/constants.go @@ -0,0 +1,66 @@ +// Location: ./go/cpex/constants.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Wire-format constants used across the encoder, decoder, and constructor +// helpers. Centralizing them prevents the silent mismatches you'd get from +// a typo in one of the three sites a value would otherwise be duplicated. +// +// All values match Rust's serde tag/field names exactly — adding a new +// variant requires syncing this file with the corresponding Rust enum. + +package cpex + +// Payload type IDs — must match Rust's PAYLOAD_* constants in +// crates/cpex-ffi/src/lib.rs. Used as the `payload_type` discriminator +// when crossing the FFI boundary. +const ( + // PayloadGeneric is a generic JSON-like payload (map[string]any). + PayloadGeneric uint8 = 0 + // PayloadCMFMessage is a CMF MessagePayload. + PayloadCMFMessage uint8 = 1 +) + +// 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 / +// decoder / constructor switches resolves to one of these. +const ( + ContentTypeText = "text" + ContentTypeThinking = "thinking" + ContentTypeToolCall = "tool_call" + ContentTypeToolResult = "tool_result" + ContentTypeResource = "resource" + ContentTypeResourceRef = "resource_ref" + ContentTypePromptRequest = "prompt_request" + ContentTypePromptResult = "prompt_result" + ContentTypeImage = "image" + ContentTypeVideo = "video" + ContentTypeAudio = "audio" + ContentTypeDocument = "document" +) + +// Wire-format keys for the ContentPart tagged-union envelope. Unexported +// because they're an internal serialization detail — users build +// ContentPart via the constructors (NewTextPart, NewToolCallPart, …) +// and never touch the wire keys directly. +const ( + wireKeyContentType = "content_type" + wireKeyContent = "content" + wireKeyText = "text" +) + +// FFI return codes from libcpex_ffi. 0 means success; negative codes +// classify the failure. Stable wire ABI with the Rust side — values must +// match `RC_*` constants in `crates/cpex-ffi/src/lib.rs`. Don't renumber. +const ( + rcOK = 0 + rcInvalidHandle = -1 + rcInvalidInput = -2 + rcParseError = -3 + rcPipelineError = -4 + rcSerializeError = -5 + rcTimeout = -6 + rcPanic = -7 +) diff --git a/go/cpex/errors.go b/go/cpex/errors.go new file mode 100644 index 00000000..4a193ae6 --- /dev/null +++ b/go/cpex/errors.go @@ -0,0 +1,88 @@ +// Location: ./go/cpex/errors.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Sentinel errors for FFI return-code classification. +// +// The libcpex_ffi C API returns int codes (0 = success, negative = +// failure). Each negative code maps to a stable sentinel error here — +// callers can use `errors.Is(err, ErrCpexTimeout)` to handle specific +// failure modes (retry on timeout, abort on panic, etc.) instead of +// regex-matching opaque "invoke failed" strings. + +package cpex + +import ( + "errors" + "fmt" +) + +// Sentinel errors returned by the FFI wrapper. Compare with `errors.Is`. +// All errors returned from PluginManager / 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)") + + // ErrCpexInvalidInput: caller-supplied input was malformed — bad + // UTF-8, null pointer where data was required, oversized buffer, + // unknown payload type. Caller bug; fix the input and retry. + ErrCpexInvalidInput = errors.New("cpex: invalid input") + + // ErrCpexParse: parse / deserialize step failed (YAML config, + // MessagePack payload, MessagePack extensions). Caller bug; fix + // the data shape and retry. + ErrCpexParse = errors.New("cpex: parse / deserialize failed") + + // ErrCpexPipeline: pipeline / lifecycle step failed — load_config + // returned Err, initialize failed, or a plugin signalled a + // runtime failure that wasn't a timeout or panic. + ErrCpexPipeline = errors.New("cpex: pipeline / lifecycle error") + + // ErrCpexSerialize: result serialization failed after the pipeline + // ran successfully. Usually OOM on rmp_serde::to_vec_named or an + // unserializable JSON value. Rare; not retryable on its own. + ErrCpexSerialize = errors.New("cpex: result serialize failed") + + // ErrCpexTimeout: wall-clock timeout exceeded inside the FFI + // boundary. The plugin is likely CPU-bound or blocking the OS + // thread without yielding (per-plugin tokio timeouts can't catch + // non-cooperative work). Caller may retry but probably wants to + // 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") +) + +// errorFromRC maps an FFI return code to a typed error. `op` is included +// in the wrapped message so the caller can tell which operation failed +// without losing the sentinel for `errors.Is` checks. +func errorFromRC(rc int, op string) error { + switch rc { + case rcOK: + return nil + case rcInvalidHandle: + return fmt.Errorf("%s: %w", op, ErrCpexInvalidHandle) + case rcInvalidInput: + return fmt.Errorf("%s: %w", op, ErrCpexInvalidInput) + case rcParseError: + return fmt.Errorf("%s: %w", op, ErrCpexParse) + case rcPipelineError: + return fmt.Errorf("%s: %w", op, ErrCpexPipeline) + case rcSerializeError: + return fmt.Errorf("%s: %w", op, ErrCpexSerialize) + case rcTimeout: + return fmt.Errorf("%s: %w", op, ErrCpexTimeout) + case rcPanic: + return fmt.Errorf("%s: %w", op, ErrCpexPanic) + default: + // Unknown code — wrap a generic error including the rc so + // the caller can at least see the raw value. + return fmt.Errorf("%s: cpex: unknown FFI return code %d", op, rc) + } +} diff --git a/go/cpex/ffi.go b/go/cpex/ffi.go new file mode 100644 index 00000000..67e9c848 --- /dev/null +++ b/go/cpex/ffi.go @@ -0,0 +1,66 @@ +// Location: ./go/cpex/ffi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 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. + +package cpex + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi + +#include +#include + +// Opaque handles +typedef void* CpexManager; +typedef void* CpexContextTable; +typedef void* CpexBackgroundTasks; + +// 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); + +// 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); + +// Invoke +int cpex_invoke( + CpexManager mgr, + 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 +); + +// Background tasks +int cpex_wait_background( + CpexManager mgr, + CpexBackgroundTasks bg_handle, + uint8_t** errors_msgpack_out, int* errors_len_out +); +void cpex_free_background(CpexBackgroundTasks bg_handle); + +// Memory +void cpex_free_context_table(CpexContextTable ct); +void cpex_free_bytes(uint8_t* ptr, int len); +*/ +import "C" diff --git a/go/cpex/go.mod b/go/cpex/go.mod new file mode 100644 index 00000000..d71e10b0 --- /dev/null +++ b/go/cpex/go.mod @@ -0,0 +1,8 @@ +module github.com/contextforge-org/contextforge-plugins-framework/go/cpex + +go 1.25.4 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) diff --git a/go/cpex/go.sum b/go/cpex/go.sum new file mode 100644 index 00000000..84eba6c9 --- /dev/null +++ b/go/cpex/go.sum @@ -0,0 +1,4 @@ +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= diff --git a/go/cpex/manager.go b/go/cpex/manager.go new file mode 100644 index 00000000..911bb7ec --- /dev/null +++ b/go/cpex/manager.go @@ -0,0 +1,550 @@ +// Location: ./go/cpex/manager.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// PluginManager — Go wrapper for the CPEX plugin runtime. +// +// Owns the lifecycle of the Rust PluginManager 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() +// +// Payloads and extensions are serialized to MessagePack when +// crossing the FFI boundary. ContextTable and BackgroundTasks +// are opaque handles to Rust-owned data. + +package cpex + +import ( + "errors" + "fmt" + "runtime" + "sync" + "unsafe" + + "github.com/vmihailenco/msgpack/v5" +) + +/* +#include +#include + +// Opaque handles +typedef void* CpexManager; +typedef void* CpexContextTable; +typedef void* CpexBackgroundTasks; + +// Extern declarations - implemented in libcpex_ffi. +// +// These are duplicated with ffi.go's preamble. CGO does NOT merge +// declarations across multiple files' preambles in a single package - +// each file's `import "C"` resolves only against its own preceding +// comment block. The reviewer's "remove these, CGO resolves +// package-wide" suggestion was tested and didn't work; CGO reports +// "could not determine what C.cpex_X refers to" for any function +// declared only in a sibling file's preamble. +// +// If you change a signature, edit BOTH this block and ffi.go's. The +// build will fail loudly on a mismatch (Go's C type system catches +// it), but the duplication is unavoidable until either: +// - all cgo entry points move to ffi.go (refactor), or +// - 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 int cpex_invoke( + CpexManager mgr, + 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 +); +extern int cpex_wait_background( + CpexManager mgr, + CpexBackgroundTasks bg_handle, + uint8_t** errors_msgpack_out, int* errors_len_out +); +extern void cpex_free_background(CpexBackgroundTasks bg_handle); +extern void cpex_free_context_table(CpexContextTable ct); +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. +// +// Concurrency: `mu` serializes lifecycle (Shutdown, finalizer) against +// in-flight cgo calls. Operations (Invoke, Initialize, queries) take +// the *read* lock and may run in parallel with each other — the +// 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 { + mu sync.RWMutex + handle C.CpexManager // protected by mu; nil after Shutdown +} + +// ContextTable holds per-plugin context state across hook invocations. +// Opaque handle to Rust-owned data — not serialized. +type ContextTable struct { + handle C.CpexContextTable +} + +// 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 +// produced this `BackgroundTasks`. +type BackgroundTasks struct { + handle C.CpexBackgroundTasks + mgr *PluginManager +} + +// 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 +// been created the runtime is fixed for the process lifetime. +// +// Precedence: ConfigureRuntime > CPEX_FFI_WORKER_THREADS env var > +// num_cpus default. Returns ErrCpexInvalidInput if workerThreads <= 0 +// or the runtime has already been initialized. +func ConfigureRuntime(workerThreads int) error { + rc := C.cpex_configure_runtime(C.int(workerThreads)) + return errorFromRC(int(rc), "ConfigureRuntime") +} + +// finalizeManager 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) { + m.mu.Lock() + defer m.mu.Unlock() + if m.handle != nil { + C.cpex_shutdown(m.handle) + m.handle = nil + } +} + +// NewPluginManager creates a manager from a YAML config string. +// Built-in Rust plugin factories are registered automatically. +func NewPluginManager(yaml string) (*PluginManager, error) { + cYaml := C.CString(yaml) + defer C.free(unsafe.Pointer(cYaml)) + + handle := C.cpex_manager_new(cYaml, C.int(len(yaml))) + if handle == nil { + return nil, errors.New("cpex: failed to create plugin manager from config") + } + + mgr := &PluginManager{handle: handle} + runtime.SetFinalizer(mgr, finalizeManager) + return mgr, nil +} + +// NewPluginManagerDefault creates a manager with default config. +// Useful when registering plugins programmatically. +func NewPluginManagerDefault() (*PluginManager, error) { + handle := C.cpex_manager_new_default() + if handle == nil { + return nil, errors.New("cpex: failed to create default plugin manager") + } + + mgr := &PluginManager{handle: handle} + runtime.SetFinalizer(mgr, finalizeManager) + return mgr, nil +} + +// FactoryRegistrar is a function that registers plugin factories on the +// manager'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, +// allowing callers to register plugin factories via their own FFI. +// Must be called before LoadConfig. +func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("RegisterFactories: %w", ErrCpexInvalidHandle) + } + return fn(unsafe.Pointer(m.handle)) +} + +// LoadConfig loads a YAML config string into the manager. +// 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 { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("LoadConfig: %w", ErrCpexInvalidHandle) + } + + cYaml := C.CString(yaml) + defer C.free(unsafe.Pointer(cYaml)) + + rc := C.cpex_load_config(m.handle, cYaml, C.int(len(yaml))) + return errorFromRC(int(rc), "LoadConfig") +} + +// Initialize calls Initialize on all registered plugins. +// Must be called before invoking any hooks. +// +// On failure, the returned error wraps one of the typed sentinels +// (ErrCpexInvalidHandle, ErrCpexPipeline, ErrCpexTimeout, ErrCpexPanic). +func (m *PluginManager) Initialize() error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("Initialize: %w", ErrCpexInvalidHandle) + } + + rc := C.cpex_initialize(m.handle) + return errorFromRC(int(rc), "Initialize") +} + +// Shutdown gracefully shuts down all plugins and releases resources. +// After this call, the manager 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() { + m.mu.Lock() + defer m.mu.Unlock() + if m.handle == nil { + return + } + // Clear the finalizer first — if cpex_shutdown panics or aborts, + // we still don't want the finalizer to run later and try again. + runtime.SetFinalizer(m, nil) + C.cpex_shutdown(m.handle) + m.handle = nil +} + +// 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 { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return false + } + cName := C.CString(hookName) + defer C.free(unsafe.Pointer(cName)) + return C.cpex_has_hooks_for(m.handle, cName, C.int(len(hookName))) == 1 +} + +// PluginCount returns the number of registered plugins. +func (m *PluginManager) PluginCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return 0 + } + return int(C.cpex_plugin_count(m.handle)) +} + +// IsInitialized reports whether Initialize has been called and Shutdown +// has not. Useful for agent control loops that may inspect manager +// state before deciding whether to dispatch. +func (m *PluginManager) IsInitialized() bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return false + } + return C.cpex_is_initialized(m.handle) == 1 +} + +// 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) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, fmt.Errorf("PluginNames: %w", ErrCpexInvalidHandle) + } + + var namesPtr *C.uint8_t + var namesLen C.int + rc := C.cpex_plugin_names(m.handle, &namesPtr, &namesLen) + if rc != 0 { + return nil, errorFromRC(int(rc), "PluginNames") + } + + bytes := C.GoBytes(unsafe.Pointer(namesPtr), namesLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(namesPtr)), namesLen) + + var names []string + if err := msgpack.Unmarshal(bytes, &names); err != nil { + return nil, fmt.Errorf("PluginNames: decode failed: %w", err) + } + return names, nil +} + +// InvokeByName invokes a hook by name with a payload and extensions. +// 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( + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, nil, nil, fmt.Errorf("InvokeByName: %w", ErrCpexInvalidHandle) + } + + // Serialize payload to MessagePack + payloadBytes, err := msgpack.Marshal(payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: payload marshal failed: %w", err) + } + + // Serialize extensions to MessagePack + var extBytes []byte + if extensions != nil { + extBytes, err = msgpack.Marshal(extensions) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: extensions marshal failed: %w", err) + } + } + + // Prepare C args + cHookName := C.CString(hookName) + defer C.free(unsafe.Pointer(cHookName)) + + // Pass the context-table handle to Rust but DO NOT nil our local + // reference until we know Rust succeeded. Rust consumes the handle + // only at the moment of invoke (after all input validation), so + // pre-invoke failures (bad payload, bad extensions, etc.) leave + // the handle untouched and the caller's ContextTable remains valid. + // + // Caveat: on a post-invoke failure (rare — only result-serialization + // OOM), Rust has consumed the box but doesn't write ctOut, so the + // caller's ContextTable handle becomes dangling. The caller should + // not reuse a ContextTable after an InvokeByName error. + var ctHandle C.CpexContextTable + if contextTable != nil { + ctHandle = contextTable.handle + } + + var resultPtr *C.uint8_t + var resultLen C.int + var ctOut C.CpexContextTable + var bgOut C.CpexBackgroundTasks + + var payloadPtr *C.uint8_t + if len(payloadBytes) > 0 { + payloadPtr = (*C.uint8_t)(unsafe.Pointer(&payloadBytes[0])) + } + + var extPtr *C.uint8_t + var extLen C.int + if len(extBytes) > 0 { + extPtr = (*C.uint8_t)(unsafe.Pointer(&extBytes[0])) + extLen = C.int(len(extBytes)) + } + + rc := C.cpex_invoke( + m.handle, + cHookName, C.int(len(hookName)), + C.uint8_t(payloadType), + payloadPtr, C.int(len(payloadBytes)), + extPtr, extLen, + ctHandle, + &resultPtr, &resultLen, + &ctOut, + &bgOut, + ) + + if rc != 0 { + return nil, nil, nil, errorFromRC(int(rc), "InvokeByName") + } + + // Rust succeeded — it consumed ctHandle and produced ctOut. + // NOW it's safe to nil the caller's reference (the original Box + // was consumed by Rust; its successor is in ctOut). + if contextTable != nil { + contextTable.handle = nil + } + + // Deserialize result from MessagePack + resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) + + var result PipelineResult + if err := msgpack.Unmarshal(resultBytes, &result); err != nil { + return nil, nil, nil, fmt.Errorf("cpex: result unmarshal failed: %w", err) + } + + // Wrap opaque handles + resultCT := &ContextTable{handle: ctOut} + runtime.SetFinalizer(resultCT, func(ct *ContextTable) { + ct.Close() + }) + + // Hold *PluginManager (not the raw C handle) so Wait() can check + // mgr.handle != nil under the manager's mutex — preventing UAF + // if Shutdown is called between this invoke and Wait(). + bg := &BackgroundTasks{handle: bgOut, mgr: m} + + return &result, resultCT, bg, nil +} + +// Invoke is the typed invoke path. Calls InvokeByName and deserializes +// the modified payload and extensions into concrete Go types. +// +// Example: +// +// result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( +// mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, +// payload, ext, nil, +// ) +// if !result.IsDenied() && result.ModifiedPayload != nil { +// fmt.Println(result.ModifiedPayload.Message.Role) +// } +func Invoke[P any]( + m *PluginManager, + hookName string, + payloadType uint8, + payload P, + extensions *Extensions, + contextTable *ContextTable, +) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) { + raw, ct, bg, err := m.InvokeByName(hookName, payloadType, payload, extensions, contextTable) + if err != nil { + return nil, nil, nil, err + } + + typed := &TypedPipelineResult[P]{ + ContinueProcessing: raw.ContinueProcessing, + Violation: raw.Violation, + Errors: raw.Errors, + Metadata: raw.Metadata, + PayloadType: raw.PayloadType, + } + + // Deserialize modified payload if present + if len(raw.ModifiedPayload) > 0 { + var v P + if err := msgpack.Unmarshal(raw.ModifiedPayload, &v); err != nil { + return nil, ct, bg, fmt.Errorf("cpex: modified payload unmarshal failed: %w", err) + } + typed.ModifiedPayload = &v + } + + // Deserialize modified extensions if present + if len(raw.ModifiedExtensions) > 0 { + var ext Extensions + if err := msgpack.Unmarshal(raw.ModifiedExtensions, &ext); err != nil { + return nil, ct, bg, fmt.Errorf("cpex: modified extensions unmarshal failed: %w", err) + } + typed.ModifiedExtensions = &ext + } + + return typed, ct, bg, nil +} + +// 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 +// returned slice is nil. +// +// Each PluginError carries the failing plugin's name, a message, an +// optional error code, structured details, and an optional protocol +// 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 +// 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 { + return nil, fmt.Errorf("BackgroundTasks.Wait: %w", ErrCpexInvalidHandle) + } + + bg.mgr.mu.RLock() + defer bg.mgr.mu.RUnlock() + if bg.mgr.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. + // 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) + } + + var errorsPtr *C.uint8_t + var errorsLen C.int + + rc := C.cpex_wait_background(bg.mgr.handle, bg.handle, &errorsPtr, &errorsLen) + bg.handle = nil // consumed by Rust regardless of rc (per P2 #11 fix) + + if rc != 0 { + // Output pointers are uninitialized on rc != 0 — must NOT + // read them. C.GoBytes(nil, 0) is safe but reading garbage + // errorsPtr / errorsLen is UB. + return nil, errorFromRC(int(rc), "BackgroundTasks.Wait") + } + + errorsBytes := C.GoBytes(unsafe.Pointer(errorsPtr), errorsLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(errorsPtr)), errorsLen) + + var pluginErrors []PluginError + if err := msgpack.Unmarshal(errorsBytes, &pluginErrors); err != nil { + return nil, fmt.Errorf("BackgroundTasks.Wait: error decode failed: %w", err) + } + return pluginErrors, nil +} + +// Close releases the background task handles without waiting. +// Tasks continue running in the Rust tokio runtime. +func (bg *BackgroundTasks) Close() { + if bg.handle == nil { + return + } + C.cpex_free_background(bg.handle) + bg.handle = nil +} + +// Close releases the Rust-owned context table. +func (ct *ContextTable) Close() { + if ct.handle == nil { + return + } + C.cpex_free_context_table(ct.handle) + ct.handle = nil +} diff --git a/go/cpex/manager_test.go b/go/cpex/manager_test.go new file mode 100644 index 00000000..f5b31c5a --- /dev/null +++ b/go/cpex/manager_test.go @@ -0,0 +1,1175 @@ +// Location: ./go/cpex/manager_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tests for the CPEX Go SDK. +// +// These tests run against the real Rust runtime via cgo. The +// libcpex_ffi staticlib must be built before running: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "sync" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +func TestNewPluginManagerDefault(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if mgr.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + } + + if mgr.HasHooksFor("test_hook") { + t.Error("expected no hooks registered") + } +} + +func TestNewPluginManagerFromYAML(t *testing.T) { + yaml := ` +plugin_settings: + plugin_timeout: 30 +` + mgr, err := NewPluginManager(yaml) + if err != nil { + t.Fatalf("NewPluginManager failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if mgr.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + } +} + +func TestNewPluginManagerInvalidYAML(t *testing.T) { + _, err := NewPluginManager("not: [valid: yaml: {{}") + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestInvokeByNameNoPlugins(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Invoke with no registered plugins — should return allowed + payload := map[string]any{ + "tool_name": "test_tool", + "user": "alice", + } + + ext := &Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "test_tool", + }, + } + + result, ctxTable, bg, err := mgr.InvokeByName("test_hook", PayloadGeneric, payload, ext, nil) + if err != nil { + t.Fatalf("InvokeByName failed: %v", err) + } + defer ctxTable.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed result with no plugins") + } + + if !result.ContinueProcessing { + t.Error("expected continue_processing=true") + } +} + +func TestInvokeByNameWithContextTableThreading(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + ext := &Extensions{} + + // First invocation — nil context table + result1, ctxTable1, bg1, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, ext, nil) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg1.Close() + + if result1.IsDenied() { + t.Error("first invoke should be allowed") + } + + // Second invocation — thread context table from first + result2, ctxTable2, bg2, err := mgr.InvokeByName("hook2", PayloadGeneric, payload, ext, ctxTable1) + if err != nil { + t.Fatalf("second invoke failed: %v", err) + } + bg2.Close() + + if result2.IsDenied() { + t.Error("second invoke should be allowed") + } + + ctxTable2.Close() +} + +func TestBackgroundTasksWait(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.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) + if err != nil { + t.Fatalf("invoke failed: %v", err) + } + defer ctxTable.Close() + + _ = result + + // Wait should return with no errors (no plugins to run) + errors, err := bg.Wait() + if err != nil { + t.Errorf("bg.Wait failed: %v", err) + } + if len(errors) > 0 { + t.Errorf("expected no background errors, got: %v", errors) + } +} + +// Concurrent goroutines invoking against a single manager 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + const goroutines = 32 + const callsPerGoroutine = 16 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + 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) + if err != nil { + t.Errorf("invoke failed: %v", err) + return + } + if ct != nil { + ct.Close() + } + if bg != nil { + _, _ = bg.Wait() + } + } + }() + } + wg.Wait() +} + +// 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. +func TestShutdownDuringInvokesIsSafe(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + // Spawn workers that invoke in a tight loop until they observe shutdown. + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + payload := map[string]any{"x": 1} + _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + if err != nil { + // Expected once Shutdown lands; just stop. + return + } + if ct != nil { + ct.Close() + } + if bg != nil { + _, _ = bg.Wait() + } + } + }() + } + + // Let them spin for a moment, then shutdown. + mgr.Shutdown() + close(stop) + wg.Wait() + + // Second Shutdown must be a no-op (P1 #4 fix — finalizer cleared, + // double-call returns immediately). + mgr.Shutdown() +} + +// BackgroundTasks.Wait() called after the manager 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. +func TestBackgroundTasksWaitAfterShutdownReturnsTypedError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, _, bg, err := mgr.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() + + // Wait must observe the shutdown handle and return a typed error + // — not panic, not segfault on a stale C pointer, not silently + // return empty. + results, err := bg.Wait() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } + if results != nil { + t.Errorf("expected nil results on error path, got %v", results) + } +} + +// Invoking with an unknown payload_type discriminator must return an +// error wrapping ErrCpexParse — the deserialize_payload registry +// rejects unknown values with RC_PARSE_ERROR, which the Go side +// classifies via errorFromRC. +func TestInvokeUnknownPayloadTypeReturnsParseError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.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) + if !errors.Is(err, ErrCpexParse) { + t.Errorf("expected ErrCpexParse for unknown payload_type, got %v", err) + } +} + +// IsInitialized reports manager 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + if mgr.IsInitialized() { + t.Error("expected IsInitialized=false before Initialize") + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + if !mgr.IsInitialized() { + t.Error("expected IsInitialized=true after Initialize") + } + + mgr.Shutdown() + if mgr.IsInitialized() { + t.Error("expected IsInitialized=false after Shutdown") + } +} + +// 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + names, err := mgr.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) + } +} + +// 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, _, bg, err := mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + + errs, err := bg.Wait() + if err != nil { + 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. + 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 +// 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + if err := mgr.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) { + t.Errorf("LoadConfig after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } + _, _, _, err = mgr.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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + mgr.Shutdown() + // Second shutdown should not panic + mgr.Shutdown() +} + +func TestContextTableDoubleClose(t *testing.T) { + ct := &ContextTable{} + ct.Close() // should not panic + ct.Close() // should not panic +} + +func TestBackgroundTasksDoubleClose(t *testing.T) { + bg := &BackgroundTasks{} + bg.Close() // should not panic + bg.Close() // should not panic +} + +func TestPipelineResultIsDenied(t *testing.T) { + allowed := PipelineResult{ContinueProcessing: true} + if allowed.IsDenied() { + t.Error("expected not denied") + } + + denied := PipelineResult{ + ContinueProcessing: false, + Violation: &PluginViolation{ + Code: "test_denied", + Reason: "test reason", + }, + } + if !denied.IsDenied() { + t.Error("expected denied") + } +} + +func TestExtensionsSerialization(t *testing.T) { + ext := Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &SecurityExtension{ + Labels: []string{"PII"}, + Classification: "confidential", + Agent: &AgentIdentity{ + ClientID: "hr-agent", + WorkloadID: "spiffe://corp.com/hr-agent", + TrustDomain: "corp.com", + }, + }, + Http: &HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer tok", + "X-Request-ID": "req-123", + }, + }, + } + + // Verify it can be marshaled without error + _, err := msgpackMarshal(ext) + if err != nil { + t.Fatalf("extensions marshal failed: %v", err) + } +} + +// msgpackMarshal is a helper that imports msgpack for the test +func msgpackMarshal(v any) ([]byte, error) { + return msgpack.Marshal(v) +} + +// --------------------------------------------------------------------------- +// Typed Invoke Tests +// --------------------------------------------------------------------------- + +func TestInvokeTypedGenericPayload(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{ + "tool_name": "test_tool", + "user": "alice", + } + + result, ct, bg, err := Invoke[map[string]any]( + mgr, "test_hook", PayloadGeneric, payload, &Extensions{}, nil, + ) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + defer ct.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed result") + } + + if !result.ContinueProcessing { + t.Error("expected continue_processing=true") + } +} + +func TestInvokeTypedCMFPayload(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + msg := MessagePayload{ + Message: NewMessage("assistant", + NewTextPart("Looking up compensation data"), + NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), + } + + ext := &Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + } + + result, ct, bg, err := Invoke[MessagePayload]( + mgr, "cmf.tool_pre_invoke", PayloadCMFMessage, msg, ext, nil, + ) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + defer ct.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed with no plugins") + } +} + +func TestInvokeTypedContextThreading(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + + // First call — nil context table + r1, ct1, bg1, err := Invoke[map[string]any]( + mgr, "hook1", PayloadGeneric, payload, &Extensions{}, nil, + ) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg1.Close() + + if r1.IsDenied() { + t.Error("first invoke should be allowed") + } + + // Second call — thread context table + r2, ct2, bg2, err := Invoke[map[string]any]( + mgr, "hook2", PayloadGeneric, payload, &Extensions{}, ct1, + ) + if err != nil { + t.Fatalf("second invoke failed: %v", err) + } + bg2.Close() + + if r2.IsDenied() { + t.Error("second invoke should be allowed") + } + + ct2.Close() +} + +func TestTypedPipelineResultIsDenied(t *testing.T) { + allowed := TypedPipelineResult[map[string]any]{ContinueProcessing: true} + if allowed.IsDenied() { + t.Error("expected not denied") + } + + denied := TypedPipelineResult[map[string]any]{ + ContinueProcessing: false, + Violation: &PluginViolation{ + Code: "test", + Reason: "denied", + }, + } + if !denied.IsDenied() { + t.Error("expected denied") + } +} + +// --------------------------------------------------------------------------- +// CMF Content Part Tests +// --------------------------------------------------------------------------- + +func TestContentPartTextRoundTrip(t *testing.T) { + part := NewTextPart("hello world") + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "text" { + t.Errorf("expected content_type=text, got %s", decoded.ContentType) + } + if decoded.Text != "hello world" { + t.Errorf("expected text='hello world', got '%s'", decoded.Text) + } +} + +func TestContentPartThinkingRoundTrip(t *testing.T) { + part := NewThinkingPart("let me analyze...") + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "thinking" { + t.Errorf("expected content_type=thinking, got %s", decoded.ContentType) + } + if decoded.Text != "let me analyze..." { + t.Errorf("expected thinking text, got '%s'", decoded.Text) + } +} + +func TestContentPartToolCallRoundTrip(t *testing.T) { + part := NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_weather", + Arguments: map[string]any{"city": "London"}, + Namespace: "tools", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "tool_call" { + t.Errorf("expected content_type=tool_call, got %s", decoded.ContentType) + } + if decoded.ToolCallContent == nil { + t.Fatal("expected ToolCallContent to be set") + } + if decoded.ToolCallContent.Name != "get_weather" { + t.Errorf("expected name=get_weather, got %s", decoded.ToolCallContent.Name) + } + if decoded.ToolCallContent.ToolCallID != "tc_001" { + t.Errorf("expected tool_call_id=tc_001, got %s", decoded.ToolCallContent.ToolCallID) + } + if decoded.ToolCallContent.Namespace != "tools" { + t.Errorf("expected namespace=tools, got %s", decoded.ToolCallContent.Namespace) + } +} + +func TestContentPartToolResultRoundTrip(t *testing.T) { + part := NewToolResultPart(ToolResult{ + ToolCallID: "tc_001", + ToolName: "get_weather", + Content: map[string]any{"temp": 20, "unit": "C"}, + IsError: false, + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "tool_result" { + t.Errorf("expected content_type=tool_result, got %s", decoded.ContentType) + } + if decoded.ToolResultContent == nil { + t.Fatal("expected ToolResultContent to be set") + } + if decoded.ToolResultContent.ToolName != "get_weather" { + t.Errorf("expected tool_name=get_weather, got %s", decoded.ToolResultContent.ToolName) + } +} + +func TestContentPartResourceRoundTrip(t *testing.T) { + part := NewResourcePart(Resource{ + ResourceRequestID: "rr_001", + URI: "file:///data.txt", + ResourceType: "file", + Content: "Hello from file", + MimeType: "text/plain", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "resource" { + t.Errorf("expected content_type=resource, got %s", decoded.ContentType) + } + if decoded.ResourceContent == nil { + t.Fatal("expected ResourceContent to be set") + } + if decoded.ResourceContent.URI != "file:///data.txt" { + t.Errorf("expected uri=file:///data.txt, got %s", decoded.ResourceContent.URI) + } + if decoded.ResourceContent.Content != "Hello from file" { + t.Errorf("expected content='Hello from file', got '%s'", decoded.ResourceContent.Content) + } +} + +func TestContentPartImageRoundTrip(t *testing.T) { + part := NewImagePart(ImageSource{ + SourceType: "url", + Data: "https://example.com/photo.jpg", + MediaType: "image/jpeg", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "image" { + t.Errorf("expected content_type=image, got %s", decoded.ContentType) + } + if decoded.ImageContent == nil { + t.Fatal("expected ImageContent to be set") + } + if decoded.ImageContent.SourceType != "url" { + t.Errorf("expected type=url, got %s", decoded.ImageContent.SourceType) + } + if decoded.ImageContent.Data != "https://example.com/photo.jpg" { + t.Errorf("expected data URL, got %s", decoded.ImageContent.Data) + } +} + +func TestContentPartDocumentRoundTrip(t *testing.T) { + part := NewDocumentPart(DocumentSource{ + SourceType: "base64", + Data: "dGVzdA==", + MediaType: "application/pdf", + Title: "Quarterly Report", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "document" { + t.Errorf("expected content_type=document, got %s", decoded.ContentType) + } + if decoded.DocumentContent == nil { + t.Fatal("expected DocumentContent to be set") + } + if decoded.DocumentContent.Title != "Quarterly Report" { + t.Errorf("expected title='Quarterly Report', got '%s'", decoded.DocumentContent.Title) + } +} + +// Regression for P2 #13 — `decodeVideoSource`/`decodeAudioSource` +// previously dropped DurationMs. With the generic decodeAs[T] helper +// driven by msgpack tags, fields can no longer be silently lost. +func TestContentPartVideoRoundTripWithDuration(t *testing.T) { + dur := uint64(15000) + part := NewVideoPart(VideoSource{ + SourceType: "url", + Data: "https://example.com/v.mp4", + MediaType: "video/mp4", + DurationMs: &dur, + }) + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.VideoContent == nil { + t.Fatal("VideoContent nil") + } + if decoded.VideoContent.DurationMs == nil || *decoded.VideoContent.DurationMs != 15000 { + t.Errorf("DurationMs lost: %v", decoded.VideoContent.DurationMs) + } +} + +// Regression for P2 #13 — `decodeResource` previously dropped Blob +// and SizeBytes; `decodeResourceRef` dropped RangeStart and RangeEnd. +func TestContentPartResourceFieldsPreserved(t *testing.T) { + size := uint64(2048) + rstart := uint64(100) + rend := uint64(500) + + resource := NewResourcePart(Resource{ + ResourceRequestID: "rr_1", + URI: "file:///doc.bin", + ResourceType: "binary", + Blob: []byte{0xDE, 0xAD, 0xBE, 0xEF}, + SizeBytes: &size, + MimeType: "application/octet-stream", + }) + data, err := msgpack.Marshal(resource) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var d1 ContentPart + if err := msgpack.Unmarshal(data, &d1); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d1.ResourceContent == nil { + t.Fatal("ResourceContent nil") + } + if string(d1.ResourceContent.Blob) != "\xDE\xAD\xBE\xEF" { + t.Errorf("Blob lost: %v", d1.ResourceContent.Blob) + } + if d1.ResourceContent.SizeBytes == nil || *d1.ResourceContent.SizeBytes != 2048 { + t.Errorf("SizeBytes lost: %v", d1.ResourceContent.SizeBytes) + } + + ref := NewResourceRefPart(ResourceReference{ + ResourceRequestID: "rr_2", + URI: "file:///doc.bin", + ResourceType: "binary", + RangeStart: &rstart, + RangeEnd: &rend, + }) + data, err = msgpack.Marshal(ref) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var d2 ContentPart + if err := msgpack.Unmarshal(data, &d2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d2.ResourceRefContent == nil { + t.Fatal("ResourceRefContent nil") + } + if d2.ResourceRefContent.RangeStart == nil || *d2.ResourceRefContent.RangeStart != 100 { + t.Errorf("RangeStart lost: %v", d2.ResourceRefContent.RangeStart) + } + if d2.ResourceRefContent.RangeEnd == nil || *d2.ResourceRefContent.RangeEnd != 500 { + t.Errorf("RangeEnd lost: %v", d2.ResourceRefContent.RangeEnd) + } +} + +// Regression for P2 #13 — `decodePromptResult` previously dropped +// the Messages field entirely (with a "TODO: nested decode" comment). +// The generic helper handles it correctly, including nested Messages +// with their own ContentPart custom decoder. +func TestContentPartPromptResultPreservesMessages(t *testing.T) { + pr := NewPromptResultPart(PromptResult{ + PromptRequestID: "pr_1", + PromptName: "summarize", + Messages: []Message{ + NewMessage("system", NewTextPart("You are concise.")), + NewMessage("user", NewTextPart("Summarize the report.")), + }, + }) + data, err := msgpack.Marshal(pr) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.PromptResultContent == nil { + t.Fatal("PromptResultContent nil") + } + if len(decoded.PromptResultContent.Messages) != 2 { + t.Fatalf("expected 2 nested messages, got %d", len(decoded.PromptResultContent.Messages)) + } + if decoded.PromptResultContent.Messages[0].Role != "system" { + t.Errorf("first nested role lost: %s", decoded.PromptResultContent.Messages[0].Role) + } + if len(decoded.PromptResultContent.Messages[1].Content) != 1 || + decoded.PromptResultContent.Messages[1].Content[0].Text != "Summarize the report." { + t.Errorf("nested content lost: %+v", decoded.PromptResultContent.Messages[1].Content) + } +} + +// Regression for P2 #17 — unknown content_type variants previously +// decoded to an empty ContentPart and re-encoded as a text fallback, +// silently dropping the original payload. Now the raw map is captured +// on decode and emitted verbatim on encode, so a future variant from +// Rust passes through an older Go SDK without data loss. +func TestContentPartUnknownContentTypeRoundTrip(t *testing.T) { + // Simulate a future Rust variant by encoding a map directly. + original := map[string]any{ + "content_type": "future_variant", + "content": map[string]any{ + "new_field": "value", + "count": uint64(42), + }, + } + wire, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Decode through ContentPart's custom decoder, then re-encode. + var cp ContentPart + if err := msgpack.Unmarshal(wire, &cp); err != nil { + t.Fatalf("decode: %v", err) + } + if cp.ContentType != "future_variant" { + t.Errorf("ContentType lost: %s", cp.ContentType) + } + roundtripped, err := msgpack.Marshal(cp) + if err != nil { + t.Fatalf("re-encode: %v", err) + } + + // Decode the roundtripped wire as a plain map and verify the + // new_field is still there. + var back map[string]any + if err := msgpack.Unmarshal(roundtripped, &back); err != nil { + t.Fatalf("unmarshal back: %v", err) + } + contentMap, ok := back["content"].(map[string]any) + if !ok { + t.Fatalf("content field missing or wrong type after roundtrip: %#v", back) + } + if contentMap["new_field"] != "value" { + t.Errorf("new_field lost across roundtrip: %#v", contentMap) + } +} + +// Regression for P2 #14, #15, #16 — Extension fields that Rust +// serializes but Go was silently dropping. Round-trip a populated +// Extensions through msgpack and verify each field survives. +func TestExtensionsAddedFieldsRoundTrip(t *testing.T) { + turn := uint32(7) + ext := &Extensions{ + Agent: &AgentExtension{ + SessionID: "sess_1", + ConversationID: "conv_1", + Turn: &turn, + AgentID: "agent_1", + Conversation: &ConversationContext{ + History: []any{"prior turn"}, + Summary: "user asked for compensation lookup", + Topics: []string{"hr", "compensation"}, + }, + }, + MCP: &MCPExtension{ + Tool: &ToolMetadata{ + Name: "get_compensation", + OutputSchema: map[string]any{"type": "object"}, + Annotations: map[string]any{"audit_required": true}, + }, + }, + Completion: &CompletionExtension{ + Model: "claude-sonnet-4-6", + RawFormat: "anthropic", + CreatedAt: "2026-05-04T10:00:00Z", + }, + } + + data, err := msgpack.Marshal(ext) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back Extensions + if err := msgpack.Unmarshal(data, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if back.Agent == nil || back.Agent.Conversation == nil { + t.Fatal("Agent.Conversation lost") + } + if back.Agent.Conversation.Summary != "user asked for compensation lookup" { + t.Errorf("Conversation.Summary lost: %q", back.Agent.Conversation.Summary) + } + if len(back.Agent.Conversation.Topics) != 2 { + t.Errorf("Conversation.Topics lost: %v", back.Agent.Conversation.Topics) + } + if back.Agent.Turn == nil || *back.Agent.Turn != 7 { + t.Errorf("Turn lost or wrong type: %v", back.Agent.Turn) + } + + if back.MCP == nil || back.MCP.Tool == nil { + t.Fatal("MCP.Tool lost") + } + if back.MCP.Tool.OutputSchema == nil { + t.Error("Tool.OutputSchema lost") + } + if back.MCP.Tool.Annotations == nil { + t.Error("Tool.Annotations lost") + } + + if back.Completion == nil { + t.Fatal("Completion lost") + } + if back.Completion.RawFormat != "anthropic" { + t.Errorf("Completion.RawFormat lost: %q", back.Completion.RawFormat) + } + if back.Completion.CreatedAt != "2026-05-04T10:00:00Z" { + t.Errorf("Completion.CreatedAt lost: %q", back.Completion.CreatedAt) + } +} + +func TestMessagePayloadSerialization(t *testing.T) { + msg := MessagePayload{ + Message: NewMessage("assistant", + NewTextPart("I'll look that up for you."), + NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), + } + + data, err := msgpack.Marshal(msg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if len(data) == 0 { + t.Fatal("expected non-empty msgpack bytes") + } + + // Verify it round-trips as a generic map (to check wire format) + var raw map[string]any + if err := msgpack.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + message, ok := raw["message"].(map[string]any) + if !ok { + t.Fatal("expected 'message' key in payload") + } + + if message["schema_version"] != "2.0" { + t.Errorf("expected schema_version=2.0, got %v", message["schema_version"]) + } + + if message["role"] != "assistant" { + t.Errorf("expected role=assistant, got %v", message["role"]) + } + + content, ok := message["content"].([]any) + if !ok { + t.Fatal("expected content to be a list") + } + + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + + // First part should be text + part0, ok := content[0].(map[string]any) + if !ok { + t.Fatal("expected content[0] to be a map") + } + if part0["content_type"] != "text" { + t.Errorf("expected content_type=text, got %v", part0["content_type"]) + } + + // Second part should be tool_call + part1, ok := content[1].(map[string]any) + if !ok { + t.Fatal("expected content[1] to be a map") + } + if part1["content_type"] != "tool_call" { + t.Errorf("expected content_type=tool_call, got %v", part1["content_type"]) + } +} + +func TestLoadConfigOnDefaultManager(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + // LoadConfig with valid YAML (no plugins, just settings) + err = mgr.LoadConfig(` +plugin_settings: + plugin_timeout: 30 +`) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } +} + +func TestLoadConfigInvalidYAML(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + err = mgr.LoadConfig("not: [valid: yaml: {{}") + if err == nil { + t.Error("expected error for invalid YAML") + } +} diff --git a/go/cpex/types.go b/go/cpex/types.go new file mode 100644 index 00000000..04cd0adc --- /dev/null +++ b/go/cpex/types.go @@ -0,0 +1,318 @@ +// Location: ./go/cpex/types.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Go types — extensions, pipeline results, and payload constants. +// +// 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/. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// Extensions carries capability-gated data alongside the payload. +// Serialized to/from MessagePack when crossing the FFI boundary. +type Extensions struct { + Meta *MetaExtension `msgpack:"meta,omitempty"` + Security *SecurityExtension `msgpack:"security,omitempty"` + Http *HttpExtension `msgpack:"http,omitempty"` + 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"` +} + +// MetaExtension carries entity identification for route resolution. +type MetaExtension struct { + EntityType string `msgpack:"entity_type,omitempty"` + EntityName string `msgpack:"entity_name,omitempty"` + Tags []string `msgpack:"tags,omitempty"` + Scope string `msgpack:"scope,omitempty"` + Properties map[string]string `msgpack:"properties,omitempty"` +} + +// SecurityExtension carries identity, labels, and data policies. +type SecurityExtension struct { + Labels []string `msgpack:"labels,omitempty"` + Classification string `msgpack:"classification,omitempty"` + Subject *SubjectExtension `msgpack:"subject,omitempty"` + Agent *AgentIdentity `msgpack:"agent,omitempty"` + AuthMethod string `msgpack:"auth_method,omitempty"` + Objects map[string]ObjectSecurityProfile `msgpack:"objects,omitempty"` + Data map[string]DataPolicy `msgpack:"data,omitempty"` +} + +// 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"` + TrustDomain string `msgpack:"trust_domain,omitempty"` +} + +// ObjectSecurityProfile is a security profile for a managed object. +type ObjectSecurityProfile struct { + ManagedBy string `msgpack:"managed_by,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + TrustDomain string `msgpack:"trust_domain,omitempty"` + DataScope []string `msgpack:"data_scope,omitempty"` +} + +// DataPolicy defines data handling policies. +type DataPolicy struct { + ApplyLabels []string `msgpack:"apply_labels,omitempty"` + AllowedActions []string `msgpack:"allowed_actions,omitempty"` + DeniedActions []string `msgpack:"denied_actions,omitempty"` + Retention *RetentionPolicy `msgpack:"retention,omitempty"` +} + +// RetentionPolicy defines data retention rules. +type RetentionPolicy struct { + MaxAgeSeconds *uint64 `msgpack:"max_age_seconds,omitempty"` + Policy string `msgpack:"policy,omitempty"` + DeleteAfter string `msgpack:"delete_after,omitempty"` +} + +// HttpExtension carries HTTP request and response headers. +type HttpExtension struct { + RequestHeaders map[string]string `msgpack:"request_headers,omitempty"` + ResponseHeaders map[string]string `msgpack:"response_headers,omitempty"` +} + +// DelegationExtension carries the token delegation chain. +type DelegationExtension struct { + Chain []DelegationHop `msgpack:"chain,omitempty"` + Depth int `msgpack:"depth,omitempty"` + OriginSubjectID string `msgpack:"origin_subject_id,omitempty"` + ActorSubjectID string `msgpack:"actor_subject_id,omitempty"` + Delegated bool `msgpack:"delegated,omitempty"` + AgeSeconds float64 `msgpack:"age_seconds,omitempty"` +} + +// DelegationHop is a single step in the delegation chain. +type DelegationHop struct { + SubjectID string `msgpack:"subject_id,omitempty"` + 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"` +} + +// AgentExtension carries agent execution context. +type AgentExtension struct { + Input string `msgpack:"input,omitempty"` + SessionID string `msgpack:"session_id,omitempty"` + ConversationID string `msgpack:"conversation_id,omitempty"` + // Turn is *uint32 to match Rust's Option. Previously *int (64-bit + // in Go) — values >2^32 would overflow the Rust side silently. + Turn *uint32 `msgpack:"turn,omitempty"` + AgentID string `msgpack:"agent_id,omitempty"` + ParentAgentID string `msgpack:"parent_agent_id,omitempty"` + // Conversation mirrors Rust's `conversation: Option`. + // Previously absent — Rust serialized this field but Go silently dropped + // it (P2 #16). + Conversation *ConversationContext `msgpack:"conversation,omitempty"` +} + +// ConversationContext is per-conversation summary state, shared across +// turns. Mirrors `cpex_core::extensions::agent::ConversationContext`. +type ConversationContext struct { + // Recent conversation history, lightweight summaries (free-form + // JSON-style values to match Rust's Vec). + History []any `msgpack:"history,omitempty"` + // LLM-generated conversation summary. + Summary string `msgpack:"summary,omitempty"` + // Detected topics for routing / classification. + Topics []string `msgpack:"topics,omitempty"` +} + +// RequestExtension carries execution environment and tracing. +type RequestExtension struct { + Environment string `msgpack:"environment,omitempty"` + RequestID string `msgpack:"request_id,omitempty"` + Timestamp string `msgpack:"timestamp,omitempty"` + TraceID string `msgpack:"trace_id,omitempty"` + SpanID string `msgpack:"span_id,omitempty"` +} + +// MCPExtension carries MCP entity metadata. +type MCPExtension struct { + Tool *ToolMetadata `msgpack:"tool,omitempty"` + Resource *ResourceMetadata `msgpack:"resource,omitempty"` + Prompt *PromptMetadata `msgpack:"prompt,omitempty"` +} + +// ToolMetadata is MCP tool metadata. +type ToolMetadata struct { + Name string `msgpack:"name"` + Title string `msgpack:"title,omitempty"` + Description string `msgpack:"description,omitempty"` + InputSchema map[string]any `msgpack:"input_schema,omitempty"` + // OutputSchema and Annotations were missing — Rust serialized them, + // Go silently dropped them (P2 #15). + OutputSchema map[string]any `msgpack:"output_schema,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` + Namespace string `msgpack:"namespace,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` +} + +// 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"` +} + +// PromptMetadata is MCP prompt metadata. +type PromptMetadata struct { + Name string `msgpack:"name"` + Description string `msgpack:"description,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// CompletionExtension carries LLM completion information. +type CompletionExtension struct { + StopReason string `msgpack:"stop_reason,omitempty"` + Tokens *TokenUsage `msgpack:"tokens,omitempty"` + Model string `msgpack:"model,omitempty"` + // RawFormat and CreatedAt were missing — Rust serialized them, + // Go silently dropped them (P2 #14). + RawFormat string `msgpack:"raw_format,omitempty"` + CreatedAt string `msgpack:"created_at,omitempty"` + LatencyMs *uint64 `msgpack:"latency_ms,omitempty"` +} + +// TokenUsage is token usage statistics. +type TokenUsage struct { + InputTokens int `msgpack:"input_tokens,omitempty"` + OutputTokens int `msgpack:"output_tokens,omitempty"` + TotalTokens int `msgpack:"total_tokens,omitempty"` +} + +// ProvenanceExtension carries origin and message threading. +type ProvenanceExtension struct { + Source string `msgpack:"source,omitempty"` + MessageID string `msgpack:"message_id,omitempty"` + ParentID string `msgpack:"parent_id,omitempty"` +} + +// LLMExtension carries model identity and capabilities. +type LLMExtension struct { + ModelID string `msgpack:"model_id,omitempty"` + Provider string `msgpack:"provider,omitempty"` + Capabilities []string `msgpack:"capabilities,omitempty"` +} + +// FrameworkExtension carries agentic framework context. +type FrameworkExtension struct { + Framework string `msgpack:"framework,omitempty"` + FrameworkVersion string `msgpack:"framework_version,omitempty"` + NodeID string `msgpack:"node_id,omitempty"` + GraphID string `msgpack:"graph_id,omitempty"` + Metadata map[string]any `msgpack:"metadata,omitempty"` +} + +// PluginViolation is a structured policy denial. +type PluginViolation struct { + Code string `msgpack:"code"` + Reason string `msgpack:"reason"` + Description string `msgpack:"description,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + PluginName string `msgpack:"plugin_name,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} + +// PluginError is a plugin execution error. +type PluginError struct { + PluginName string `msgpack:"plugin_name"` + Message string `msgpack:"message"` + Code string `msgpack:"code,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} + +// PipelineResult is the aggregate result from a hook invocation. +type PipelineResult struct { + ContinueProcessing bool `msgpack:"continue_processing"` + Violation *PluginViolation `msgpack:"violation,omitempty"` + // Errors from plugins that ran with on_error: ignore or + // on_error: disable. Empty when no plugin errored on a non-halt + // path. Fire-and-forget errors live on BackgroundTasks.Wait() + // instead. + Errors []PluginError `msgpack:"errors,omitempty"` + Metadata map[string]any `msgpack:"metadata,omitempty"` + // Payload type ID — tells the caller how to deserialize ModifiedPayload. + PayloadType uint8 `msgpack:"payload_type"` + // Modified payload as raw MessagePack bytes. + ModifiedPayload []byte `msgpack:"modified_payload,omitempty"` + // Modified extensions as raw MessagePack bytes. + ModifiedExtensions []byte `msgpack:"modified_extensions,omitempty"` +} + +// TypedPipelineResult is a PipelineResult with the modified payload +// and extensions deserialized into concrete Go types. +type TypedPipelineResult[P any] struct { + ContinueProcessing bool + Violation *PluginViolation + Errors []PluginError + Metadata map[string]any + PayloadType uint8 + ModifiedPayload *P + ModifiedExtensions *Extensions +} + +// IsDenied returns true if the pipeline was halted by a plugin. +func (r *TypedPipelineResult[P]) IsDenied() bool { + return !r.ContinueProcessing +} + +// DeserializePayload deserializes the modified payload into a typed struct. +func DeserializePayload[T any](result *PipelineResult) (*T, error) { + if len(result.ModifiedPayload) == 0 { + return nil, nil + } + var v T + if err := msgpack.Unmarshal(result.ModifiedPayload, &v); err != nil { + return nil, err + } + return &v, nil +} + +// DeserializeExtensions deserializes the modified extensions. +func (r *PipelineResult) DeserializeExtensions() (*Extensions, error) { + if len(r.ModifiedExtensions) == 0 { + return nil, nil + } + var ext Extensions + if err := msgpack.Unmarshal(r.ModifiedExtensions, &ext); err != nil { + return nil, err + } + return &ext, nil +} + +// IsDenied returns true if the pipeline was halted by a plugin. +func (r *PipelineResult) IsDenied() bool { + return !r.ContinueProcessing +} From cc7ee603807ae4af69cad51d5d2042cb45f71f89 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 11 May 2026 09:23:38 -0600 Subject: [PATCH 05/64] docs: intial rust specification (#50) Co-authored-by: Teryl Taylor --- docs/specs/cpex-rust-spec.md | 1424 ++++++++++++++++++++++++++++++++++ 1 file changed, 1424 insertions(+) create mode 100644 docs/specs/cpex-rust-spec.md diff --git a/docs/specs/cpex-rust-spec.md b/docs/specs/cpex-rust-spec.md new file mode 100644 index 00000000..eeb2be29 --- /dev/null +++ b/docs/specs/cpex-rust-spec.md @@ -0,0 +1,1424 @@ +# CPEX Rust — Public API Specification + +**Status**: Draft +**Date**: May 2026 +**Source**: `crates/cpex-core` in `github.com/contextforge-org/contextforge-plugins-framework` + +CPEX Rust is the core plugin runtime — pure Rust, no FFI/WASM/PyO3 dependencies. It serves two audiences: + +- **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 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). + +## 1. Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Rust Host │ +│ │ +│ PluginManager ───────────────────────────────┐ │ +│ │ PluginManager::new(ManagerConfig) │ │ +│ │ PluginManager::from_config(path, factories)│ │ +│ │ register_handler::(plugin, config) │ │ +│ │ initialize().await │ │ +│ │ invoke::(payload, ext, ct).await │ │ +│ │ invoke_named::(name, payload, ext, ct) │ │ +│ │ has_hooks_for(name) / plugin_count() │ │ +│ │ shutdown().await │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ │ +├────────────────────────┼─────────────────────────────┤ +│ 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 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ │ +├────────────────────────┼─────────────────────────────┤ +│ Plugins ▼ │ +│ impl Plugin + impl HookHandler │ +│ • Capability-gated extension reads/writes │ +│ • Async lifecycle, sync handle() │ +└──────────────────────────────────────────────────────┘ +``` + +**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. +- **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" } +async-trait = "0.1" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +**Module overview** (everything is `pub` unless marked): + +| 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. + +## 3. Lifecycle + +``` +ManagerConfig::default() → PluginManager::new() + │ + ▼ +register_factory(kind, factory) ← optional, for kind-driven loading + │ + ▼ +load_config(yaml) | register_handler::(...) ← either YAML or programmatic + │ + ▼ +initialize().await ← calls Plugin::initialize() on each + │ + ▼ +invoke::(payload, ext, ct).await ← repeatable; can be concurrent (preferred typed path) + │ + ▼ +shutdown().await ← calls Plugin::shutdown() on each +``` + +## 4. Quick Reference + +| Operation | Method | +|---|---| +| Create manager | `PluginManager::new(ManagerConfig::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)` | +| Programmatic register | `mgr.register_handler::(plugin, config)` | +| Multiple hook names | `mgr.register_handler_for_names::(plugin, config, &names)` | +| Initialize | `mgr.initialize().await` | +| Query lifecycle | `mgr.is_initialized()` | +| Check hooks exist | `mgr.has_hooks_for(name)` | +| Count plugins | `mgr.plugin_count()` | +| List plugins | `mgr.plugin_names()` | +| Get plugin | `mgr.get_plugin(name)` → `Option>` | +| **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 payload | `impl_plugin_payload!(MyPayload)` | +| Handler | `impl HookHandler for MyPlugin { async fn handle(...) -> H::Result { ... } }` | +| Allow result | `PluginResult::allow()` | +| Deny result | `PluginResult::deny(violation)` | +| Modify payload | `PluginResult::modify_payload(p)` | +| Modify extensions | `PluginResult::modify_extensions(owned)` | +| Wait background | `bg.wait().await` | +| Shutdown | `mgr.shutdown().await` | +| Unregister | `mgr.unregister(name)` | + +## 5. Core Types + +### 5.1 PluginManager + +The top-level object. Owns the plugin registry, factory registry, hook adapter table, and executor. + +```rust +pub struct PluginManager { /* private — uses ArcSwap */ } + +impl PluginManager { + // Construction + pub fn new(config: ManagerConfig) -> Self; + pub fn default() -> Self; // ManagerConfig::default() + pub fn from_config( + path: &Path, + factories: &PluginFactoryRegistry, + ) -> Result>; + + // Factory registration + pub fn register_factory( + &self, + kind: impl Into, + factory: Box, + ); + + // YAML loading + pub fn load_config_file(&self, path: &Path) -> Result<(), Box>; + pub fn load_config(&self, cpex: CpexConfig) -> Result<(), Box>; + + // Programmatic plugin registration (preferred for native plugins) + pub fn register_handler( + &self, + plugin: Arc

, + config: PluginConfig, + ) -> Result<(), Box> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static; + + 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; + + pub fn register_raw( + &self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), Box>; + + // Lifecycle + pub async fn initialize(&self) -> Result<(), Box>; + pub async fn shutdown(&self); + pub fn is_initialized(&self) -> bool; + + // Query + pub fn has_hooks_for(&self, name: &str) -> bool; + pub fn plugin_count(&self) -> usize; + pub fn plugin_names(&self) -> Vec; + 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 + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks); + + pub async fn invoke_named( + &self, + hook_name: &str, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks); + + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks); +} +``` + +**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. +- 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. + +### 5.2 Choosing an Invoke Method + +Three flavors exist. **Default to `invoke::`.** The other two are for specific scenarios. + +```rust +// Primary: typed payload, hook name from H::NAME +let (result, bg) = mgr.invoke::(payload, ext, ct).await; + +// CMF pattern: typed payload, runtime hook name +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; +``` + +| Method | Payload type | Hook name source | When to use | +|---|---|---|---| +| `invoke::` | `H::Payload` (compile-time checked) | `H::NAME` constant | **Default for Rust callers.** Compiler verifies payload type matches the hook. One hook → one type. | +| `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. + +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 + +```rust +pub struct ManagerConfig { + pub default_timeout: Duration, + pub default_on_error: OnError, + pub max_route_cache_size: usize, + /* additional fields — see manager.rs */ +} + +impl Default for ManagerConfig { /* 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. + +### 5.4 PluginConfig + +The declarative shape that drives plugin loading and runtime behavior. One entry per `plugins:` item in the YAML. + +```rust +pub struct PluginConfig { + pub name: String, // unique identifier + pub kind: String, // factory key (e.g., "builtin/identity") + pub description: Option, + pub author: Option, + pub version: Option, + pub hooks: Vec, // hook names this plugin handles + pub mode: PluginMode, // sequential / transform / audit / concurrent / fire_and_forget / disabled + pub priority: i32, // lower = earlier within phase (default 100) + 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 config: Option, // plugin-specific settings (opaque to framework) +} +``` + +`config: Option` is where plugin-specific knobs live. The framework hands the JSON value to the plugin's factory; the factory deserializes it into a typed config struct. + +### 5.5 PluginMode + +```rust +#[non_exhaustive] +pub enum PluginMode { + Sequential, // serial, can block + modify + Transform, // serial, can modify (cannot block) + Audit, // serial, read-only + Concurrent, // parallel, can block (cannot modify) + FireAndForget, // background, cannot block or modify + Disabled, // skipped +} + +impl PluginMode { + pub fn can_block(&self) -> bool; // Sequential | Concurrent + pub fn can_modify(&self) -> bool; // Sequential | Transform + pub fn is_awaited(&self) -> bool; // not FireAndForget or Disabled +} +``` + +Modes determine *both* the phase the plugin runs in *and* the authority it has. The executor enforces this: + +| Mode | Phase | Receives | Can Block? | Can Modify? | +|---|---|---|---|---| +| `Sequential` | 1 | owned (clone) | Yes | Yes | +| `Transform` | 2 | owned (clone) | No | Yes | +| `Audit` | 3 | `&Payload` | No | No | +| `Concurrent` | 4 | `&Payload` | Yes | No | +| `FireAndForget` | 5 | `&Payload` | No | No | +| `Disabled` | — | not invoked | — | — | + +### 5.6 OnError + +```rust +#[non_exhaustive] +pub enum OnError { + Fail, // halt pipeline (default) + Ignore, // log + record in PipelineResult.errors, continue + Disable, // log + record + auto-disable for the rest of process lifetime +} +``` + +`Ignore` and `Disable` failures land in `PipelineResult.errors` (a `Vec`); `Fail` failures halt the pipeline and surface via `PipelineResult.continue_processing == false` plus a populated `violation`. + +### 5.7 PipelineResult + +```rust +pub struct PipelineResult { + pub continue_processing: bool, + pub violation: Option, + pub modified_payload: Option>, + pub modified_extensions: Option, + pub metadata: HashMap, + pub errors: Vec, + pub context_table: PluginContextTable, +} + +impl PipelineResult { + pub fn is_denied(&self) -> bool; + pub fn allow() -> Self; + pub fn with_errors(self, errors: Vec) -> Self; +} +``` + +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. + +### 5.8 PluginResult<P> + +The **per-handler** result type, distinct from the per-invoke `PipelineResult`. Each `HookHandler::handle(...)` returns one of these. + +```rust +pub struct PluginResult { + pub continue_processing: bool, + pub violation: Option, + pub modified_payload: Option

, + pub modified_extensions: Option, + pub metadata: HashMap, +} + +impl PluginResult

{ + pub fn allow() -> Self; + pub fn deny(violation: PluginViolation) -> Self; + pub fn modify_payload(payload: P) -> Self; + pub fn modify_extensions(extensions: OwnedExtensions) -> Self; + pub fn modify(payload: P, extensions: OwnedExtensions) -> Self; + pub fn has_modifications(&self) -> bool; +} +``` + +Plugin authors use the four constructors; manual struct construction is rare. The executor merges per-plugin `PluginResult

`s into the final `PipelineResult`. + +To read a typed modified payload back from a `PipelineResult`: + +```rust +if let Some(boxed) = result.modified_payload.as_ref() { + if let Some(typed) = boxed.as_any().downcast_ref::() { + // typed: &ToolInvokePayload + } +} +``` + +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 + +The framework's error type. All public functions return `Result>`. + +```rust +#[derive(Debug, Error)] +pub enum PluginError { + Execution { + plugin_name: String, + message: String, + source: Option>, + code: Option, + details: HashMap, + proto_error_code: Option, + }, + Timeout { plugin_name: String, timeout_ms: u64, proto_error_code: Option }, + Violation { plugin_name: String, violation: PluginViolation }, + Config { message: String }, + UnknownHook { hook_type: String }, +} + +impl PluginError { + pub fn boxed(self) -> Box; // sugar for Box::new(self) +} +``` + +**Why boxed:** the enum is ~184 bytes (large `details` HashMap, `source` trait object). `Result>` keeps the success path pointer-sized; the allocation only happens on the error path. This is the standard Rust pattern for rich error types and is enforced by `clippy::result_large_err`. + +Construction is ergonomic: + +```rust +return Err(PluginError::Config { + message: "missing policy_file".into(), +}.boxed()); + +// `?` works automatically — From for Box is in std: +let cfg: MyConfig = serde_json::from_value(raw)?; // serde error ↗ Box +``` + +### 5.10 PluginViolation + +Structured denial. Returned by plugins that want to halt the pipeline with a reason. + +```rust +pub struct PluginViolation { + pub code: String, // machine-readable identifier + pub reason: String, // short human-readable explanation + pub description: Option, // longer detail + pub details: HashMap, // structured diagnostic data + pub plugin_name: Option, // set by framework after return + pub proto_error_code: Option, // wire-protocol error code +} + +impl PluginViolation { + pub fn new(code: impl Into, reason: impl Into) -> Self; + pub fn with_description(self, description: impl Into) -> Self; + pub fn with_details(self, details: HashMap) -> Self; + pub fn with_proto_error_code(self, code: i64) -> Self; +} +``` + +### 5.11 PluginErrorRecord + +`Clone`-able snapshot of a `PluginError`. Lives in `PipelineResult.errors`. `PluginError` itself can't be `Clone` (the `source: Box` is not cloneable) and errors crossing the FFI boundary need `Serialize`/`Deserialize`. + +```rust +#[derive(Clone, Serialize, Deserialize)] +pub struct PluginErrorRecord { + pub plugin_name: String, + pub message: String, + pub code: Option, + pub details: HashMap, + pub proto_error_code: Option, +} + +impl From<&PluginError> for PluginErrorRecord { /* ... */ } +impl From<&Box> for PluginErrorRecord { /* forwarder */ } +``` + +The `From<&Box>` forwarder exists so call sites that hold `e: Box` can write `(&e).into()` without a manual deref. + +## 6. Plugin Authoring + +This is the heart of the API for plugin authors. The full minimal plugin is: + +1. Define a payload type and `impl_plugin_payload!` it. +2. Define a hook type implementing `HookTypeDef`. +3. Implement `Plugin` for the plugin struct. +4. Implement `HookHandler` for each hook the plugin handles. +5. Register the plugin (via `register_handler` or a `PluginFactory`). + +§11 walks through this end-to-end. This section explains each piece. + +### 6.1 The `Plugin` Trait + +Every plugin implements `Plugin`. It carries the plugin's config and the lifecycle hooks. + +```rust +#[async_trait] +pub trait Plugin: Send + Sync { + /// The plugin's configuration. Read-only — the framework holds + /// the authoritative copy in `PluginRef.trusted_config`. + fn config(&self) -> &PluginConfig; + + /// One-time initialization. Called before any invokes. + /// Use to open connections, load resources, validate config. + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + /// Graceful shutdown. Called once during teardown. + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} +``` + +Default implementations for `initialize`/`shutdown` are no-ops; override only if your plugin needs them. + +### 6.2 The `HookHandler` Trait + +Each hook the plugin handles requires a separate `HookHandler` impl. The type parameter `H` is the hook type (a marker struct implementing `HookTypeDef`). + +```rust +pub trait HookHandler: Plugin + Send + Sync { + fn handle( + &self, + payload: &H::Payload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> impl std::future::Future + Send; +} +``` + +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 { + async fn handle( + &self, + _payload: &MyPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +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(/* ... */), + } + } +} +``` + +**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(...)`. + +**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. + +```rust +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. + +**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::`. + +```rust +pub trait PluginPayload: Send + Sync + 'static { + fn clone_boxed(&self) -> Box; + fn as_any(&self) -> &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; +} +``` + +Implement it via the macro: + +```rust +use cpex_core::impl_plugin_payload; + +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: serde_json::Value, +} +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. + +### 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}; + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "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. +- 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) + +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; + +define_hook! { + /// Hook for tool_pre_invoke. + ToolPreInvoke; + "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. + +### 6.5 PluginResult Constructors + +The four canonical outcomes a plugin signals: + +| Constructor | What it signals | +|---|---| +| `PluginResult::allow()` | Pass. No changes. | +| `PluginResult::deny(violation)` | Halt the pipeline. Caller sees `result.is_denied() == true`. | +| `PluginResult::modify_payload(p)` | Pass. Replace the payload in flight (Sequential/Transform only). | +| `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). + +### 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. + +```rust +impl HookHandler for IdentityResolver { + async fn handle(&self, p: &ToolInvokePayload, e: &Extensions, c: &mut PluginContext) + -> PluginResult + { /* ... */ } +} + +impl HookHandler for IdentityResolver { + async fn handle(&self, p: &ToolInvokePayload, e: &Extensions, c: &mut PluginContext) + -> PluginResult + { /* ... */ } +} +``` + +Register each separately: + +```rust +manager.register_handler::( + Arc::clone(&plugin), config_for("tool_pre_invoke"))?; +manager.register_handler::( + plugin, config_for("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: + +```rust +manager.register_handler_for_names::( + plugin, + config, + &[ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + "cmf.llm_output", + ], +)?; +``` + +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. + +```rust +use cpex_core::hooks::payload::Extensions; + +async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, +) -> PluginResult { + let mut owned = extensions.cow_copy(); + + // http_write_token is Some(...) iff the plugin declared `write_headers` + 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", &payload.message.role); + h.set_response_header("X-CPEX-Processed", "true"); + } + } + + PluginResult::modify_extensions(owned) +} +``` + +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): + +| Capability | Grants | +|---|---| +| `read_subject` | `SecurityExtension.subject` (read) | +| `read_labels` | `SecurityExtension.labels` (read) | +| `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) | +| `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. + +### 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. + +`Ignore` and `Disable` failures are recorded in `PipelineResult.errors` — they are not silent. + +## 7. Factories & Registration + +Two registration paths: + +| Path | When to use | +|---|---| +| `register_handler::` | You construct the plugin in Rust (compiled into the host). Direct, no factory needed. | +| `from_config(path, &factories)` | Plugin set is determined by YAML at runtime. Each `kind` in YAML maps to a registered `PluginFactory`. | + +You can mix them: register infrastructure plugins programmatically, then `load_config` to add YAML-configured ones. + +### 7.1 The PluginFactory Trait + +```rust +pub trait PluginFactory: Send + Sync { + fn create(&self, config: &PluginConfig) -> Result>; +} +``` + +A factory takes a `PluginConfig` (one entry from `plugins:` in YAML) and produces a `PluginInstance`. It's responsible for: + +1. Constructing the plugin (`Arc::new(MyPlugin { ... })`). +2. Building one `TypedHandlerAdapter` per hook the plugin handles. +3. Returning the bundle as a `PluginInstance`. + +### 7.2 PluginInstance + +```rust +pub struct PluginInstance { + pub plugin: Arc, + pub handlers: Vec<(&'static str, Arc)>, +} +``` + +`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. + +### 7.3 PluginFactoryRegistry + +```rust +pub struct PluginFactoryRegistry { /* private */ } + +impl PluginFactoryRegistry { + pub fn new() -> Self; + pub fn register(&mut self, kind: impl Into, factory: Box); + pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory>; + pub fn has(&self, kind: &str) -> bool; + pub fn kinds(&self) -> Vec<&str>; +} +``` + +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: ..." }`. + +## 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. + +```rust +pub struct Extensions { + pub meta: Option>, + pub security: Option>, + pub http: Option>, + pub delegation: Option>, + pub agent: Option>, + pub request: Option>, + pub mcp: Option>, + pub completion: Option>, + pub provenance: Option>, + pub llm: Option>, + pub framework: Option>, + pub custom: HashMap, +} +``` + +| Extension | Purpose | +|---|---| +| `Meta` | Entity identification for route resolution (`entity_type`, `entity_name`, `tags`) | +| `Security` | Identity, labels, classification, data policies, authmethod, agent identity | +| `Http` | Request/response headers | +| `Delegation` | Token delegation chain (per-hop subject, audience, scope) | +| `Agent` | Agent execution context (session, conversation, turn) | +| `Request` | Environment, request ID, trace/span IDs, timestamp | +| `MCP` | MCP entity metadata (tool/resource/prompt server IDs) | +| `Completion` | LLM stats (stop reason, tokens, model, latency) | +| `Provenance` | Origin and message threading | +| `LLM` | Model identity (provider, capabilities) | +| `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()`. + +For capability-gated writes, see §6.7. + +## 9. CMF Payloads & Hooks + +**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 serde_json::json; + +let msg = MessagePayload { + message: Message { + schema_version: "1.0".into(), + role: Role::User, + content: vec![ + ContentPart::Text("Look up compensation".into()), + ContentPart::ToolCall(ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: json!({"employee_id": 42}), + ..Default::default() + }), + ], + channel: None, + }, +}; +``` + +`MessagePayload` already implements `PluginPayload` — no `impl_plugin_payload!` needed. + +**Built-in CMF hooks** (registered when you wire CMF into the manager): + +| Hook | Purpose | +|---|---| +| `cmf.tool_pre_invoke` | Before tool execution | +| `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.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. + +## 10. YAML Configuration + +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] + +plugins: + - name: identity-resolver + kind: builtin/identity # must match a registered factory key + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: [read_subject] + config: # opaque to framework — passed to factory + strict_mode: true + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: [read_labels, read_subject] + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] # adds tags to MetaExtension for matching tools + plugins: + - audit-logger # route-specific override + + - tool: list_departments + plugins: + - audit-logger + + - tool: "*" # wildcard — catch-all + plugins: + - audit-logger +``` + +**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. + +**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. + +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)); + +let manager = PluginManager::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). + +### 11.1 Define the Payload + +```rust +use cpex_core::impl_plugin_payload; + +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} +impl_plugin_payload!(ToolInvokePayload); +``` + +### 11.2 Define the Hook Types + +```rust +use cpex_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"; +} + +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} +``` + +### 11.3 Implement the Plugin + +```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}; + +/// Plugin that requires a non-empty `user` field on every invocation. +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(()) + } +} +``` + +### 11.4 Implement the Hook Handlers + +```rust +impl HookHandler for IdentityResolver { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + PluginResult::allow() + } +} + +impl HookHandler for IdentityResolver { + async fn handle( + &self, + _payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} +``` + +### 11.5 Build a Factory + +```rust +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::registry::AnyHookHandler; + +struct IdentityFactory; + +impl PluginFactory for IdentityFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityResolver { cfg: config.clone() }); + + 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", + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), + )), + "tool_post_invoke" => handlers.push(( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), + )), + other => return Err(PluginError::Config { + message: format!("identity-resolver doesn't handle hook '{}'", other), + }.boxed()), + } + } + + Ok(PluginInstance { + plugin: plugin as Arc, + handlers, + }) + } +} +``` + +### 11.6 Register and Invoke (Programmatic) + +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}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let manager = PluginManager::new(ManagerConfig::default()); + + // Programmatic registration — skips the factory entirely. + let cfg = PluginConfig { + name: "identity-resolver".into(), + kind: "builtin/identity".into(), + hooks: vec!["tool_pre_invoke".into()], + mode: PluginMode::Sequential, + on_error: OnError::Fail, + ..Default::default() + }; + let plugin = Arc::new(IdentityResolver { cfg: cfg.clone() }); + manager.register_handler::(plugin, cfg)?; + + manager.initialize().await?; + + // Typed invoke — payload type must be ToolPreInvoke::Payload (= ToolInvokePayload). + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: r#"{"employee_id": 42}"#.into(), + }; + + let (result, _bg) = manager.invoke::( + payload, + Extensions::default(), + None, + ).await; + + if result.is_denied() { + let v = result.violation.unwrap(); + eprintln!("DENIED: {} [{}]", v.reason, v.code); + } else { + println!("ALLOWED"); + } + + // Soft errors (on_error: ignore/disable plugins) land here. + for record in &result.errors { + eprintln!( + "soft error from {}: {}", + record.plugin_name, record.message, + ); + } + + manager.shutdown().await; + Ok(()) +} +``` + +### 11.7 Threading Context Across Hooks + +For pre/post hook pairs, thread the returned `PluginContextTable` from the pre-hook into the post-hook so each plugin sees its own `local_state` from earlier: + +```rust +let (pre_result, _bg) = manager.invoke::( + payload.clone(), ext.clone(), None, +).await; + +// Tool runs here ... +let tool_output = run_tool(&payload).await?; + +// Post-hook: pass pre_result.context_table so plugins see their stashed local_state. +let (post_result, _bg) = manager.invoke::( + payload, ext, Some(pre_result.context_table), +).await; +``` + +The first invoke takes `None`; subsequent invokes within the same logical request thread `Some(prev.context_table)` through. + +### 11.8 Register and Invoke (Config-driven) + +For YAML-driven registration, register the factory and call `from_config`: + +```rust +use cpex_core::factory::PluginFactoryRegistry; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut factories = PluginFactoryRegistry::new(); + factories.register("builtin/identity", Box::new(IdentityFactory)); + + let manager = PluginManager::from_config( + Path::new("plugins.yaml"), + &factories, + )?; + manager.initialize().await?; + + /* same invoke:: as above */ + + manager.shutdown().await; + Ok(()) +} +``` + +## 12. PluginContext & State + +Every `HookHandler::handle` call receives a `&mut PluginContext`. It carries two state stores: + +```rust +pub struct PluginContext { + pub plugin_id: PluginId, + pub local_state: HashMap, // per-plugin, persists across hooks + pub global_state: HashMap, // shared across plugins, scoped to one invoke chain + /* helpers */ +} +``` + +| 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) | +| `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. + +`global_state` is committed back to a canonical store after each plugin runs (in Sequential phase) so the next plugin sees the merged view. + +## 13. Error Handling + +The framework surfaces failures through three channels, each with distinct semantics: + +| Channel | Triggers | Where it shows up | +|---|---|---| +| `Result<_, Box>` from `register_*`, `load_config`, `initialize` | Lifecycle errors: parse error, factory error, initialization error | Caller's `Err(...)` | +| `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. + +```rust +let (result, _bg) = mgr.invoke::(payload, ext, ct).await; + +if !result.continue_processing { + let v = result.violation.unwrap(); + eprintln!("denied [{}]: {}", v.code, v.reason); + return; // halt downstream work +} + +// Soft errors — pipeline ran, but some plugins failed non-fatally. +for record in &result.errors { + log::warn!( + "plugin {} failed: {} ({})", + record.plugin_name, record.message, + record.code.as_deref().unwrap_or("-"), + ); +} +``` + +## 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. +- 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. +- 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. + +```rust +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::context::PluginContext; + use cpex_core::hooks::payload::Extensions; + use cpex_core::plugin::PluginId; + + #[tokio::test] + async fn rejects_empty_user() { + let plugin = IdentityResolver { + cfg: PluginConfig { name: "test".into(), ..Default::default() }, + }; + let payload = ToolInvokePayload { + tool_name: "test".into(), + user: "".into(), + arguments: "{}".into(), + }; + let mut ctx = PluginContext::new(PluginId::from(1)); + let result = HookHandler::::handle( + &plugin, &payload, &Extensions::default(), &mut ctx).await; + assert!(result.violation.is_some()); + assert_eq!(result.violation.unwrap().code, "no_identity"); + } +} +``` + +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). + +## 16. Build & Test + +The repo Makefile is the canonical interface: + +| Target | What it does | +|---|---| +| `make rust-build` / `rust-build-release` | Build workspace (debug / release) | +| `make rust-test` | Full workspace tests | +| `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-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 + +# Run example +cargo run --example plugin_demo -p cpex-core +cargo run --example cmf_capabilities_demo -p cpex-core + +# Tests +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." + +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: + +``` +HookHandler ← native AFIT, monomorphized inside the plugin's binary + ↓ wrapped by TypedHandlerAdapter at registration time +AnyHookHandler ← object-safe; #[async_trait] boxes the future + ↓ vtable +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. + +### 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`. | + +### 17.2 Async stays end-to-end + +Both transports preserve full async behavior: + +``` +host: arc_handler.invoke(payload, ext, ctx).await + ↓ (vtable call across the module boundary) +plugin: TypedHandlerAdapter::invoke + ↓ (downcast to H::Payload + .await) +plugin: handle(...).await // plugin can await JWKS, RPC, anything +``` + +`#[async_trait]` boxes the typed future into `Pin>` at the `AnyHookHandler` boundary. That boxed future is what crosses the module line. The host awaits it on its own tokio runtime; the plugin's `.await` points are pause points inside that future. + +### 17.3 Constraints and gotchas + +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. +- **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: + +- **Rust ABI instability.** Plugin and host must be compiled with the same compiler version *and* same dependency versions. Different versions = UB. Mitigations: pin both, ship the host crate as a `=` version requirement, or use the `abi_stable` crate (gives a C-compatible vtable at the cost of an extra layer). +- **Allocator boundaries.** A `Box`/`Arc` allocated by the plugin must be dropped by the same allocator. The simplest path is for both sides to use the system allocator; otherwise the plugin must expose a free function the host calls on drop. +- **Symbol visibility.** The plugin's registration entry point must be `#[no_mangle] pub extern "C"` so `dlsym` can find it. Everything else can stay regular Rust. + +### 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. + +## 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 | +| 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 | +| WASM plugin loader | `cpex-hosts::wasm` (planned) | Not yet implemented | +| 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. From 869ce0d8cd2281e101e1bf10e48bb1f86ec889c4 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 11 May 2026 09:54:09 -0600 Subject: [PATCH 06/64] feat: change Plugin handler to async for performance (#49) Co-authored-by: Teryl Taylor --- .../examples/cmf_capabilities_demo.rs | 6 +- crates/cpex-core/examples/plugin_demo.rs | 149 +++++++++++++++++- crates/cpex-core/examples/plugin_demo.yaml | 25 +++ crates/cpex-core/src/hooks/adapter.rs | 27 +++- crates/cpex-core/src/hooks/trait_def.rs | 70 ++++++-- crates/cpex-core/src/manager.rs | 123 ++++++++++++++- crates/cpex-ffi/src/lib.rs | 2 +- examples/go-demo/ffi/src/cmf_plugins.rs | 4 +- examples/go-demo/ffi/src/demo_plugins.rs | 6 +- 9 files changed, 375 insertions(+), 37 deletions(-) diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs index 230c5a36..8843a30e 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -42,7 +42,7 @@ impl Plugin for IdentityChecker { } impl HookHandler for IdentityChecker { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, @@ -136,7 +136,7 @@ impl Plugin for HeaderInjector { } impl HookHandler for HeaderInjector { - fn handle( + async fn handle( &self, _payload: &MessagePayload, extensions: &Extensions, @@ -201,7 +201,7 @@ impl Plugin for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index f0d28f6d..12cfd3f5 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -76,7 +76,7 @@ impl Plugin for IdentityResolver { } impl HookHandler for IdentityResolver { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -98,7 +98,7 @@ impl HookHandler for IdentityResolver { } impl HookHandler for IdentityResolver { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -126,7 +126,7 @@ impl Plugin for PiiGuard { } impl HookHandler for PiiGuard { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -171,7 +171,7 @@ impl Plugin for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -186,7 +186,7 @@ impl HookHandler for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -200,6 +200,89 @@ impl HookHandler for AuditLogger { } } +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- @@ -264,6 +347,27 @@ impl PluginFactory for AuditLoggerFactory { } } +/// 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 // --------------------------------------------------------------------------- @@ -318,6 +422,7 @@ async fn main() { 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"); @@ -401,8 +506,38 @@ async fn main() { print_result("some_other_tool (wildcard)", &result); bg.wait_for_background_tasks().await; - // --- Scenario 5: No user identity --- - println!("=== Scenario 5: list_departments (no user identity) ===\n"); + // --- 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(), diff --git a/crates/cpex-core/examples/plugin_demo.yaml b/crates/cpex-core/examples/plugin_demo.yaml index 9e3dd610..07051e95 100644 --- a/crates/cpex-core/examples/plugin_demo.yaml +++ b/crates/cpex-core/examples/plugin_demo.yaml @@ -15,6 +15,10 @@ global: # "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 @@ -33,6 +37,17 @@ plugins: 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] @@ -53,6 +68,16 @@ routes: 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: diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index 7acc7b12..985108ca 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -3,14 +3,20 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// TypedHandlerAdapter — bridges typed HookHandler to type-erased -// AnyHookHandler. +// 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 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; @@ -33,6 +39,11 @@ use crate::registry::AnyHookHandler; /// 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`). @@ -73,12 +84,18 @@ where P: Plugin + HookHandler + 'static, { /// Downcast the type-erased payload to the concrete type and call - /// the plugin's typed `handle()` method. + /// 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, @@ -97,7 +114,7 @@ where ), })?; - let result = self.plugin.handle(typed_ref, extensions, ctx); + let result = self.plugin.handle(typed_ref, extensions, ctx).await; let plugin_result: PluginResult = result.into(); Ok(erase_result(plugin_result)) diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index e07c7ab0..b75d2b8a 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -78,28 +78,70 @@ pub trait HookTypeDef: Send + Sync + 'static { /// 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. +/// compile time. The framework creates a type-erased adapter internally +/// when you register — you never touch `AnyHookHandler` directly. /// -/// 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 -/// impl HookHandler for MyPlugin { -/// fn handle( +/// // Synchronous plugin — no .await, no extra cost +/// impl HookHandler for AllowPlugin { +/// async fn handle( /// &self, -/// payload: MessagePayload, -/// extensions: &Extensions, -/// ctx: &PluginContext, +/// _payload: &MessagePayload, +/// _extensions: &Extensions, +/// _ctx: &mut PluginContext, /// ) -> PluginResult { /// PluginResult::allow() /// } /// } /// -/// // Registration — no AnyHookHandler needed: -/// manager.register_handler::(plugin, config)?; +/// // 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. /// @@ -112,12 +154,18 @@ pub trait HookHandler: Plugin + Send + Sync { /// 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, - ) -> H::Result; + ) -> impl std::future::Future + Send; } // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 3ad5977a..16764d49 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -1280,7 +1280,7 @@ mod tests { } impl HookHandler for AllowPlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -1309,7 +1309,7 @@ mod tests { } impl HookHandler for DenyPlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -2068,7 +2068,7 @@ mod tests { } impl HookHandler for TransformPlugin { - fn handle( + async fn handle( &self, payload: &TestPayload, _extensions: &Extensions, @@ -3348,7 +3348,7 @@ routes: } } impl HookHandler for LifecyclePlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -4089,7 +4089,7 @@ routes: } impl HookHandler for InitTrackingPlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -4842,4 +4842,117 @@ routes: // With filter_extensions, security IS Some but with empty labels and no subject // So saw_security will be true, but the content is filtered } + + // ----------------------------------------------------------------------- + // Awaiting handler tests + // + // `HookHandler` is async by design. These tests cover handlers + // that genuinely `.await` inside the body — sleeps, yields, and + // co-registration with handlers whose body has no `.await` at all. + // ----------------------------------------------------------------------- + + /// 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-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index f8bb615f..760f62d9 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -1032,7 +1032,7 @@ mod tests { } impl cpex_core::hooks::HookHandler for PanickingPlugin { - fn handle( + async fn handle( &self, _payload: &GenericPayload, _extensions: &Extensions, diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index f59d9e84..a033576f 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -68,7 +68,7 @@ impl Plugin for ToolPolicyPlugin { } impl HookHandler for ToolPolicyPlugin { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, @@ -197,7 +197,7 @@ impl Plugin for HeaderInjectorPlugin { } impl HookHandler for HeaderInjectorPlugin { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index 84351929..f27125c9 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -61,7 +61,7 @@ impl Plugin for IdentityChecker { } impl HookHandler for IdentityChecker { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, @@ -130,7 +130,7 @@ impl Plugin for PiiGuard { } impl HookHandler for PiiGuard { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, @@ -212,7 +212,7 @@ impl Plugin for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, From ca306f3fcc68e4b7a2a0e5745126f0671b34a793 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:31:59 -0600 Subject: [PATCH 07/64] fix: missing cmf-demo main.go file and gitignore fix that missed it (#52) Co-authored-by: Teryl Taylor --- .gitignore | 27 ++- examples/go-demo/.gitignore | 11 +- examples/go-demo/cmd/cmf-demo/main.go | 272 ++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 examples/go-demo/cmd/cmf-demo/main.go diff --git a/.gitignore b/.gitignore index 59e72586..35b98221 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ token.txt cpex.sbom.xml docs/docs/test/ docs/resources/ -tmp *.tgz *.gz *.bz @@ -48,8 +47,10 @@ node_modules/ mcp.db-journal mcp.db-shm mcp.db-wal -certs/ -jwt/ +# Anchored: matches only ./certs/ at the repo root, not nested +# `certs/` directories under source. Bare `certs/` would silently +# hide any cert-management module / test fixture at any depth. +/certs/ FIXMEs *.old logs/ @@ -62,19 +63,22 @@ corpus/ tests/fuzz/fuzzers/results/ .venv mcp.db -public/ +# Anchored to repo root — bare `public/` would shadow any nested +# `public/` directory in source (common in web/frontend code). +/public/ ica_integrations_host.sbom.json .pyre dictionary.dic pdm.lock .pdm-python temp/ -public/ *history.md htmlcov test_commands.md cover.md -build/ +# Anchored: bare `build/` would shadow any nested build-output dir +# anywhere in the source tree. +/build/ .icaenv commands_output.txt commands_output.md @@ -94,7 +98,6 @@ scribeflow.log coverage_re bin/flagged flagged/ -certs/ # VENV .python37/ .python39/ @@ -111,16 +114,20 @@ __pycache__/ # C extensions *.so -# Distribution / packaging +# Distribution / packaging — Python build artifacts. `build/` and +# `lib/` are anchored (root-only) so they don't silently hide +# nested source dirs of the same name. Other patterns (`dist/`, +# `downloads/`, `eggs/`, …) stay bare — they're less likely to +# collide with source-tree directory names. .wily/ .Python -build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ -lib/ +# Anchored: bare `lib/` would shadow any nested `lib/` source dir. +/lib/ lib64/ parts/ sdist/ diff --git a/examples/go-demo/.gitignore b/examples/go-demo/.gitignore index 8123b755..6d4b7557 100644 --- a/examples/go-demo/.gitignore +++ b/examples/go-demo/.gitignore @@ -1,3 +1,8 @@ -# Built demo binaries -cpex-demo -cmf-demo +# Built demo binaries. Patterns are anchored (leading slash) so they +# match *files* at their build-output locations, not arbitrary path +# components — the previous unanchored `cmf-demo` rule was silently +# ignoring the `cmd/cmf-demo/` source directory and everything under +# it. +/cpex-demo +/cmf-demo +/cmd/cmf-demo/cmf-demo diff --git a/examples/go-demo/cmd/cmf-demo/main.go b/examples/go-demo/cmd/cmf-demo/main.go new file mode 100644 index 00000000..c550d33a --- /dev/null +++ b/examples/go-demo/cmd/cmf-demo/main.go @@ -0,0 +1,272 @@ +// Location: ./examples/go-demo/cmd/cmf-demo/main.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX CMF Demo — typed message processing with rich extensions. +// +// Demonstrates CMF (ContextForge Message Format) message processing +// through the CPEX plugin pipeline: +// +// 1. Build typed CMF messages (tool calls, tool results) +// 2. Attach security extensions (labels, subject), HTTP headers, +// and agent context +// 3. Invoke cmf.tool_pre_invoke — policy checks tool permissions +// against security labels and meta tags +// 4. Invoke cmf.tool_post_invoke — header injector adds response +// headers using capability-gated write access +// 5. Inspect modified extensions (injected headers) in results +// +// Build & run: +// +// cd examples/go-demo/ffi && cargo build --release +// cd examples/go-demo && go run ./cmd/cmf-demo + +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../../../target/release -lcpex_demo_ffi -lm -ldl -lpthread -framework CoreFoundation -framework Security +#include + +int cpex_demo_register_factories(void* mgr); +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +func main() { + fmt.Println("=== CPEX CMF Demo ===") + fmt.Println() + + // --- Setup --- + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + fatal("create manager: %v", err) + } + defer mgr.Shutdown() + + err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.cpex_demo_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil + }) + if err != nil { + fatal("register factories: %v", err) + } + + yaml, err := os.ReadFile("../../cmf_plugins.yaml") + if err != nil { + // Try current directory too + yaml, err = os.ReadFile("cmf_plugins.yaml") + if err != nil { + fatal("read config: %v", err) + } + } + + if err := mgr.LoadConfig(string(yaml)); err != nil { + fatal("load config: %v", err) + } + if err := mgr.Initialize(); err != nil { + fatal("initialize: %v", err) + } + + fmt.Printf("Plugins loaded: %d\n", mgr.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"), + ) + + // ----------------------------------------------------------------------- + // Scenario 1: PII tool call WITHOUT security label — DENIED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 1: get_compensation tool call (no PII label) ===") + fmt.Println() + + msg := cpex.MessagePayload{ + Message: cpex.NewMessage("assistant", + cpex.NewTextPart("I'll look up the compensation data for you."), + cpex.NewToolCallPart(cpex.ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + Namespace: "hr", + }), + ), + } + + ext := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &cpex.SecurityExtension{ + Labels: []string{}, // no PII label — should be denied + Subject: &cpex.SubjectExtension{ + ID: "alice", + Roles: []string{"hr_analyst"}, + }, + }, + Http: &cpex.HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer eyJ...", + "X-Request-ID": "req-001", + }, + }, + Agent: &cpex.AgentExtension{ + SessionID: "sess_abc123", + AgentID: "hr-assistant", + }, + } + + result, ct, bg, err := mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, msg, ext, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 2: PII tool call WITH security label — ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 2: get_compensation tool call (with PII label) ===") + fmt.Println() + + ext.Security.Labels = []string{"PII", "HR"} // now has PII label + + result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, msg, ext, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + + // Check for modified extensions (header injector adds response headers) + // Check for modified extensions (header injector adds response headers) + if len(result.ModifiedExtensions) > 0 { + modExt, err := result.DeserializeExtensions() + if err != nil { + fmt.Printf(" (failed to deserialize modified extensions: %v)\n\n", err) + } else if modExt != nil && modExt.Http != nil && len(modExt.Http.ResponseHeaders) > 0 { + fmt.Println(" Modified response headers:") + for k, v := range modExt.Http.ResponseHeaders { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + } + } + bg.Close() + + // ----------------------------------------------------------------------- + // Scenario 3: Post-invoke with tool result — header injection + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 3: tool result post-invoke (header injection) ===") + fmt.Println() + + resultMsg := cpex.MessagePayload{ + Message: cpex.NewMessage("tool", + cpex.NewToolResultPart(cpex.ToolResult{ + ToolCallID: "tc_001", + ToolName: "get_compensation", + Content: map[string]any{ + "employee_id": 42, + "salary": 125000, + "currency": "USD", + }, + IsError: false, + }), + ), + } + + postExt := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &cpex.SecurityExtension{ + Labels: []string{"PII", "HR"}, + }, + Http: &cpex.HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer eyJ...", + "X-Request-ID": "req-001", + }, + }, + } + + result2, ct2, bg2, err := mgr.InvokeByName("cmf.tool_post_invoke", + cpex.PayloadCMFMessage, resultMsg, postExt, ct) + if err != nil { + fatal("post-invoke: %v", err) + } + printResult(result2) + + if len(result2.ModifiedExtensions) > 0 { + modExt, err := result2.DeserializeExtensions() + if err != nil { + fmt.Printf(" (failed to deserialize modified extensions: %v)\n\n", err) + } else if modExt != nil && modExt.Http != nil { + fmt.Println(" Modified response headers:") + for k, v := range modExt.Http.ResponseHeaders { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + } + } + bg2.Close() + ct2.Close() + + // ----------------------------------------------------------------------- + // Scenario 4: Non-PII tool — allowed, no policy restriction + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 4: list_departments (non-PII, text message) ===") + fmt.Println() + + textMsg := cpex.MessagePayload{ + Message: cpex.NewMessage("user", + cpex.NewTextPart("Show me the list of departments"), + ), + } + + textExt := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + } + + result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, textMsg, textExt, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + bg.Close() + ct.Close() + + fmt.Println("=== CMF Demo complete ===") +} + +func printResult(result *cpex.PipelineResult) { + if !result.IsDenied() { + fmt.Printf(" Result: ALLOWED\n\n") + } else { + v := result.Violation + fmt.Printf(" Result: DENIED — %s [%s]\n\n", v.Reason, v.Code) + } +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...) + os.Exit(1) +} From 6366b55d83b2f3b96dca7a42dcec0eef84e13326 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:37:04 -0600 Subject: [PATCH 08/64] feat: initial APL Rust implementation (#60) * fix: initial revision APL. * feat: apl-cpex bridge crate + plugin-registry-driven hook dispatch * feat: add support for plugin calling in APL routes. * feat: add more APL plugin support, unified config * feat: added cedar direct PDP. * feat: add identity hook and extensions. * feat: added token delegation hooks and tests. * feat: added plugin for jwt token identity, oauth and biscuit delegation, cedarling PDP. Signed-off-by: Teryl Taylor * fix: updated identity and delegation to support keycloak. added delegate() function, and identity sections. * fix: added some sample plugins, added updates to support cedar. Signed-off-by: Teryl Taylor * feat: added session support, serialize and parallel and full effects capabilities. * feat: add ffi pre-built .a library Signed-off-by: Frederico Araujo * chore: add workflow_dispatch target Signed-off-by: Frederico Araujo * fix: critical and high issues from review. * feat: add APL FFI and go bindings Signed-off-by: Frederico Araujo * chore: add musl tools to musl runners Signed-off-by: Frederico Araujo * fix: potential double free after use bug. * chore: update Go module paths after repo rename to cpex * feat: map identity extension into cpex ffi Signed-off-by: Frederico Araujo * feat: add cpex_invoke_resolved abi Signed-off-by: Frederico Araujo * fix: has_hook_for handling Signed-off-by: Frederico Araujo * chore: update headers Signed-off-by: Frederico Araujo --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Frederico Araujo --- .github/workflows/release-ffi.yaml | 190 + .gitignore | 19 +- CHANGELOG.md | 30 + Cargo.lock | 4968 +++++++++++++++-- Cargo.toml | 40 + crates/apl-audit-logger/Cargo.toml | 29 + crates/apl-audit-logger/src/config.rs | 33 + crates/apl-audit-logger/src/factory.rs | 55 + crates/apl-audit-logger/src/lib.rs | 40 + crates/apl-audit-logger/src/logger.rs | 254 + crates/apl-cedarling/Cargo.toml | 64 + crates/apl-cedarling/src/error.rs | 30 + crates/apl-cedarling/src/identity/mod.rs | 31 + crates/apl-cedarling/src/lib.rs | 48 + crates/apl-cedarling/src/pdp/mod.rs | 10 + crates/apl-cedarling/src/pdp/resolver.rs | 374 ++ crates/apl-cedarling/tests/pdp_basic.rs | 166 + crates/apl-cmf/Cargo.toml | 24 + crates/apl-cmf/src/agent.rs | 68 + crates/apl-cmf/src/capability_namespaces.rs | 312 ++ crates/apl-cmf/src/completion.rs | 76 + crates/apl-cmf/src/constants.rs | 113 + crates/apl-cmf/src/custom.rs | 42 + crates/apl-cmf/src/delegation.rs | 88 + crates/apl-cmf/src/extensions_bridge.rs | 94 + crates/apl-cmf/src/framework.rs | 55 + crates/apl-cmf/src/http.rs | 45 + crates/apl-cmf/src/lib.rs | 137 + crates/apl-cmf/src/llm.rs | 44 + crates/apl-cmf/src/mcp.rs | 92 + crates/apl-cmf/src/meta.rs | 57 + crates/apl-cmf/src/payload.rs | 150 + crates/apl-cmf/src/provenance.rs | 38 + crates/apl-cmf/src/request.rs | 54 + crates/apl-cmf/src/security.rs | 525 ++ crates/apl-cmf/tests/end_to_end.rs | 275 + crates/apl-core/Cargo.toml | 32 + crates/apl-core/src/attributes.rs | 215 + crates/apl-core/src/evaluator.rs | 2563 +++++++++ crates/apl-core/src/lib.rs | 45 + crates/apl-core/src/parser.rs | 3929 +++++++++++++ crates/apl-core/src/pipeline.rs | 134 + crates/apl-core/src/plugin_decl.rs | 290 + crates/apl-core/src/route.rs | 739 +++ crates/apl-core/src/rules.rs | 840 +++ crates/apl-core/src/step.rs | 506 ++ crates/apl-core/tests/yaml_end_to_end.rs | 266 + crates/apl-cpex/Cargo.toml | 43 + crates/apl-cpex/src/cmf_invoker.rs | 410 ++ crates/apl-cpex/src/delegation_invoker.rs | 269 + crates/apl-cpex/src/dispatch_plan.rs | 461 ++ crates/apl-cpex/src/lib.rs | 52 + crates/apl-cpex/src/parallel_safety.rs | 343 ++ crates/apl-cpex/src/pdp_router.rs | 207 + crates/apl-cpex/src/register.rs | 185 + crates/apl-cpex/src/route_handler.rs | 569 ++ crates/apl-cpex/src/session_resolver.rs | 432 ++ crates/apl-cpex/src/session_store.rs | 157 + crates/apl-cpex/src/visitor.rs | 680 +++ crates/apl-cpex/tests/capability_gating.rs | 446 ++ crates/apl-cpex/tests/cmf_invoker_dispatch.rs | 699 +++ crates/apl-cpex/tests/config_override.rs | 519 ++ crates/apl-cpex/tests/delegate_step_e2e.rs | 913 +++ crates/apl-cpex/tests/end_to_end_route.rs | 551 ++ crates/apl-cpex/tests/visitor_e2e.rs | 705 +++ crates/apl-delegator-biscuit/Cargo.toml | 67 + crates/apl-delegator-biscuit/src/config.rs | 161 + crates/apl-delegator-biscuit/src/delegator.rs | 279 + crates/apl-delegator-biscuit/src/lib.rs | 33 + .../tests/biscuit_e2e.rs | 316 ++ crates/apl-delegator-oauth/Cargo.toml | 69 + crates/apl-delegator-oauth/src/config.rs | 158 + crates/apl-delegator-oauth/src/delegator.rs | 474 ++ crates/apl-delegator-oauth/src/factory.rs | 59 + crates/apl-delegator-oauth/src/lib.rs | 29 + crates/apl-delegator-oauth/tests/oauth_e2e.rs | 382 ++ crates/apl-identity-jwt/Cargo.toml | 92 + crates/apl-identity-jwt/src/claim_map.rs | 401 ++ crates/apl-identity-jwt/src/config.rs | 511 ++ crates/apl-identity-jwt/src/factory.rs | 60 + crates/apl-identity-jwt/src/lib.rs | 61 + crates/apl-identity-jwt/src/resolver.rs | 834 +++ crates/apl-identity-jwt/src/trusted_issuer.rs | 198 + crates/apl-identity-jwt/tests/jwks_url_e2e.rs | 750 +++ crates/apl-identity-jwt/tests/jwt_e2e.rs | 298 + crates/apl-pdp-cedar-direct/Cargo.toml | 63 + .../apl-pdp-cedar-direct/src/cedar_attrs.rs | 61 + crates/apl-pdp-cedar-direct/src/decision.rs | 127 + crates/apl-pdp-cedar-direct/src/entities.rs | 253 + crates/apl-pdp-cedar-direct/src/error.rs | 67 + crates/apl-pdp-cedar-direct/src/factory.rs | 54 + crates/apl-pdp-cedar-direct/src/lib.rs | 114 + crates/apl-pdp-cedar-direct/src/request.rs | 216 + crates/apl-pdp-cedar-direct/src/resolver.rs | 301 + crates/apl-pdp-cedar-direct/src/template.rs | 281 + .../tests/basic_allow_deny.rs | 220 + .../tests/visitor_pdp_config.rs | 166 + crates/apl-pii-scanner/Cargo.toml | 28 + crates/apl-pii-scanner/src/config.rs | 85 + crates/apl-pii-scanner/src/factory.rs | 70 + crates/apl-pii-scanner/src/lib.rs | 30 + crates/apl-pii-scanner/src/scanner.rs | 322 ++ crates/cpex-core/Cargo.toml | 9 + crates/cpex-core/src/cmf/constants.rs | 31 + crates/cpex-core/src/cmf/message.rs | 14 + crates/cpex-core/src/config.rs | 709 +++ crates/cpex-core/src/delegation/hook.rs | 86 + crates/cpex-core/src/delegation/mod.rs | 21 + crates/cpex-core/src/delegation/payload.rs | 694 +++ crates/cpex-core/src/executor.rs | 279 +- .../cpex-core/src/extensions/authorization.rs | 81 + crates/cpex-core/src/extensions/container.rs | 43 + crates/cpex-core/src/extensions/delegation.rs | 75 +- crates/cpex-core/src/extensions/filter.rs | 324 +- crates/cpex-core/src/extensions/mod.rs | 13 +- .../src/extensions/raw_credentials.rs | 342 ++ crates/cpex-core/src/extensions/security.rs | 263 +- crates/cpex-core/src/extensions/tiers.rs | 72 +- crates/cpex-core/src/hooks/metadata.rs | 369 ++ crates/cpex-core/src/hooks/mod.rs | 2 + crates/cpex-core/src/identity/hook.rs | 99 + crates/cpex-core/src/identity/mod.rs | 25 + crates/cpex-core/src/identity/payload.rs | 460 ++ crates/cpex-core/src/identity/route_config.rs | 202 + crates/cpex-core/src/lib.rs | 7 + crates/cpex-core/src/manager.rs | 653 ++- crates/cpex-core/src/registry.rs | 18 + crates/cpex-core/src/visitor.rs | 134 + crates/cpex-core/tests/delegation_e2e.rs | 722 +++ crates/cpex-core/tests/identity_e2e.rs | 744 +++ crates/cpex-core/tests/identity_route_e2e.rs | 867 +++ crates/cpex-ffi/Cargo.toml | 19 + crates/cpex-ffi/RELEASE.md | 231 + crates/cpex-ffi/src/apl.rs | 101 + crates/cpex-ffi/src/lib.rs | 489 +- crates/cpex-orchestration/Cargo.toml | 34 + crates/cpex-orchestration/src/lib.rs | 449 ++ examples/go-demo/ffi/src/cmf_plugins.rs | 2 +- examples/go-demo/ffi/src/demo_plugins.rs | 2 +- examples/go-demo/ffi/src/lib.rs | 8 +- examples/go-demo/go.mod | 6 +- examples/go-demo/main.go | 2 +- go/cpex/README.md | 2 +- go/cpex/abi.go | 50 + go/cpex/apl.go | 61 + go/cpex/apl_test.go | 73 + go/cpex/constants.go | 4 + go/cpex/go.mod | 2 +- go/cpex/identity.go | 83 + go/cpex/manager.go | 154 +- go/cpex/manager_test.go | 110 + scripts/download-ffi-artifact.sh | 169 + scripts/release/build-artifact.sh | 120 + scripts/release/sign-artifact.sh | 61 + 154 files changed, 42426 insertions(+), 724 deletions(-) create mode 100644 .github/workflows/release-ffi.yaml create mode 100644 crates/apl-audit-logger/Cargo.toml create mode 100644 crates/apl-audit-logger/src/config.rs create mode 100644 crates/apl-audit-logger/src/factory.rs create mode 100644 crates/apl-audit-logger/src/lib.rs create mode 100644 crates/apl-audit-logger/src/logger.rs create mode 100644 crates/apl-cedarling/Cargo.toml create mode 100644 crates/apl-cedarling/src/error.rs create mode 100644 crates/apl-cedarling/src/identity/mod.rs create mode 100644 crates/apl-cedarling/src/lib.rs create mode 100644 crates/apl-cedarling/src/pdp/mod.rs create mode 100644 crates/apl-cedarling/src/pdp/resolver.rs create mode 100644 crates/apl-cedarling/tests/pdp_basic.rs create mode 100644 crates/apl-cmf/Cargo.toml create mode 100644 crates/apl-cmf/src/agent.rs create mode 100644 crates/apl-cmf/src/capability_namespaces.rs create mode 100644 crates/apl-cmf/src/completion.rs create mode 100644 crates/apl-cmf/src/constants.rs create mode 100644 crates/apl-cmf/src/custom.rs create mode 100644 crates/apl-cmf/src/delegation.rs create mode 100644 crates/apl-cmf/src/extensions_bridge.rs create mode 100644 crates/apl-cmf/src/framework.rs create mode 100644 crates/apl-cmf/src/http.rs create mode 100644 crates/apl-cmf/src/lib.rs create mode 100644 crates/apl-cmf/src/llm.rs create mode 100644 crates/apl-cmf/src/mcp.rs create mode 100644 crates/apl-cmf/src/meta.rs create mode 100644 crates/apl-cmf/src/payload.rs create mode 100644 crates/apl-cmf/src/provenance.rs create mode 100644 crates/apl-cmf/src/request.rs create mode 100644 crates/apl-cmf/src/security.rs create mode 100644 crates/apl-cmf/tests/end_to_end.rs create mode 100644 crates/apl-core/Cargo.toml create mode 100644 crates/apl-core/src/attributes.rs create mode 100644 crates/apl-core/src/evaluator.rs create mode 100644 crates/apl-core/src/lib.rs create mode 100644 crates/apl-core/src/parser.rs create mode 100644 crates/apl-core/src/pipeline.rs create mode 100644 crates/apl-core/src/plugin_decl.rs create mode 100644 crates/apl-core/src/route.rs create mode 100644 crates/apl-core/src/rules.rs create mode 100644 crates/apl-core/src/step.rs create mode 100644 crates/apl-core/tests/yaml_end_to_end.rs create mode 100644 crates/apl-cpex/Cargo.toml create mode 100644 crates/apl-cpex/src/cmf_invoker.rs create mode 100644 crates/apl-cpex/src/delegation_invoker.rs create mode 100644 crates/apl-cpex/src/dispatch_plan.rs create mode 100644 crates/apl-cpex/src/lib.rs create mode 100644 crates/apl-cpex/src/parallel_safety.rs create mode 100644 crates/apl-cpex/src/pdp_router.rs create mode 100644 crates/apl-cpex/src/register.rs create mode 100644 crates/apl-cpex/src/route_handler.rs create mode 100644 crates/apl-cpex/src/session_resolver.rs create mode 100644 crates/apl-cpex/src/session_store.rs create mode 100644 crates/apl-cpex/src/visitor.rs create mode 100644 crates/apl-cpex/tests/capability_gating.rs create mode 100644 crates/apl-cpex/tests/cmf_invoker_dispatch.rs create mode 100644 crates/apl-cpex/tests/config_override.rs create mode 100644 crates/apl-cpex/tests/delegate_step_e2e.rs create mode 100644 crates/apl-cpex/tests/end_to_end_route.rs create mode 100644 crates/apl-cpex/tests/visitor_e2e.rs create mode 100644 crates/apl-delegator-biscuit/Cargo.toml create mode 100644 crates/apl-delegator-biscuit/src/config.rs create mode 100644 crates/apl-delegator-biscuit/src/delegator.rs create mode 100644 crates/apl-delegator-biscuit/src/lib.rs create mode 100644 crates/apl-delegator-biscuit/tests/biscuit_e2e.rs create mode 100644 crates/apl-delegator-oauth/Cargo.toml create mode 100644 crates/apl-delegator-oauth/src/config.rs create mode 100644 crates/apl-delegator-oauth/src/delegator.rs create mode 100644 crates/apl-delegator-oauth/src/factory.rs create mode 100644 crates/apl-delegator-oauth/src/lib.rs create mode 100644 crates/apl-delegator-oauth/tests/oauth_e2e.rs create mode 100644 crates/apl-identity-jwt/Cargo.toml create mode 100644 crates/apl-identity-jwt/src/claim_map.rs create mode 100644 crates/apl-identity-jwt/src/config.rs create mode 100644 crates/apl-identity-jwt/src/factory.rs create mode 100644 crates/apl-identity-jwt/src/lib.rs create mode 100644 crates/apl-identity-jwt/src/resolver.rs create mode 100644 crates/apl-identity-jwt/src/trusted_issuer.rs create mode 100644 crates/apl-identity-jwt/tests/jwks_url_e2e.rs create mode 100644 crates/apl-identity-jwt/tests/jwt_e2e.rs create mode 100644 crates/apl-pdp-cedar-direct/Cargo.toml create mode 100644 crates/apl-pdp-cedar-direct/src/cedar_attrs.rs create mode 100644 crates/apl-pdp-cedar-direct/src/decision.rs create mode 100644 crates/apl-pdp-cedar-direct/src/entities.rs create mode 100644 crates/apl-pdp-cedar-direct/src/error.rs create mode 100644 crates/apl-pdp-cedar-direct/src/factory.rs create mode 100644 crates/apl-pdp-cedar-direct/src/lib.rs create mode 100644 crates/apl-pdp-cedar-direct/src/request.rs create mode 100644 crates/apl-pdp-cedar-direct/src/resolver.rs create mode 100644 crates/apl-pdp-cedar-direct/src/template.rs create mode 100644 crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs create mode 100644 crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs create mode 100644 crates/apl-pii-scanner/Cargo.toml create mode 100644 crates/apl-pii-scanner/src/config.rs create mode 100644 crates/apl-pii-scanner/src/factory.rs create mode 100644 crates/apl-pii-scanner/src/lib.rs create mode 100644 crates/apl-pii-scanner/src/scanner.rs create mode 100644 crates/cpex-core/src/delegation/hook.rs create mode 100644 crates/cpex-core/src/delegation/mod.rs create mode 100644 crates/cpex-core/src/delegation/payload.rs create mode 100644 crates/cpex-core/src/extensions/authorization.rs create mode 100644 crates/cpex-core/src/extensions/raw_credentials.rs create mode 100644 crates/cpex-core/src/hooks/metadata.rs create mode 100644 crates/cpex-core/src/identity/hook.rs create mode 100644 crates/cpex-core/src/identity/mod.rs create mode 100644 crates/cpex-core/src/identity/payload.rs create mode 100644 crates/cpex-core/src/identity/route_config.rs create mode 100644 crates/cpex-core/src/visitor.rs create mode 100644 crates/cpex-core/tests/delegation_e2e.rs create mode 100644 crates/cpex-core/tests/identity_e2e.rs create mode 100644 crates/cpex-core/tests/identity_route_e2e.rs create mode 100644 crates/cpex-ffi/RELEASE.md create mode 100644 crates/cpex-ffi/src/apl.rs create mode 100644 crates/cpex-orchestration/Cargo.toml create mode 100644 crates/cpex-orchestration/src/lib.rs create mode 100644 go/cpex/abi.go create mode 100644 go/cpex/apl.go create mode 100644 go/cpex/apl_test.go create mode 100644 go/cpex/identity.go create mode 100755 scripts/download-ffi-artifact.sh create mode 100755 scripts/release/build-artifact.sh create mode 100755 scripts/release/sign-artifact.sh diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml new file mode 100644 index 00000000..9506d426 --- /dev/null +++ b/.github/workflows/release-ffi.yaml @@ -0,0 +1,190 @@ +# =============================================================== +# Release FFI - Build, sign, and publish libcpex_ffi.a artifacts +# =============================================================== +# +# Triggered by semver-strict tag pushes. Matrix-builds the FFI +# static library for the supported target tuples, packages each into +# a tarball with VERSION / FFI_ABI / LICENSE metadata, signs every +# tarball + the aggregate SHA256SUMS with cosign keyless (Sigstore), +# and attaches everything to the GitHub Release for the tag. +# +# See crates/cpex-ffi/RELEASE.md for the artifact schema and the +# consumer-side verify-and-unpack recipe. + +name: Release FFI + +on: + push: + tags: + # Semver-strict. Two patterns so vMAJOR.MINOR.PATCH (release) + # and vMAJOR.MINOR.PATCH- (rc / beta / ffi.test) + # both fire, while loose `v*` matches (vendor-bump, v1, v-foo) + # and the legacy non-prefixed tags (0.1.0, plugins.dev1) do not. + # Dry-run tags like v0.0.0-ffi.test.1 deliberately hit the + # prerelease branch. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: + +# id-token: write is what unlocks Sigstore keyless signing (Fulcio +# reads the GHA OIDC token to issue the short-lived signing cert). +# contents: write is needed to create / upload to the GitHub Release. +permissions: + contents: write + id-token: write + +# Prevent concurrent runs on the same tag from racing the release +# creation. Tag pushes are one-shot, so this is belt-and-suspenders. +concurrency: + group: release-ffi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false # one tuple failing should not cancel the others + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-22.04-arm + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + - target: aarch64-unknown-linux-musl + runner: ubuntu-22.04-arm + - target: aarch64-apple-darwin + runner: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + # dtolnay/rust-toolchain is the de-facto rustup action. + # `stable` picks the latest stable; pin if we need a floor. + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + # Share cache across tags (the key includes the target + + # Cargo.lock hash by default). Significant speedup on + # subsequent releases of the same target. + 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 + # 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 + # it. Scoped to the musl legs; gnu/darwin use their default cc. + if: contains(matrix.target, 'musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools musl-dev + + - name: Build artifact + env: + TARGET: ${{ matrix.target }} + # VERSION drops the leading "refs/tags/" so the tarball + # name matches the tag verbatim. + VERSION: ${{ github.ref_name }} + DIST_DIR: dist + # Point cc-rs and the linker at musl-gcc for the musl targets. + # No-ops for gnu/darwin (those triples don't match these keys). + CC_x86_64_unknown_linux_musl: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + run: bash scripts/release/build-artifact.sh + + - name: Upload tarball + sha256 + uses: actions/upload-artifact@v4 + with: + # Unique per-target name so the download step in + # sign-and-release can merge them all into one dist/. + name: cpex-ffi-${{ github.ref_name }}-${{ matrix.target }} + path: dist/cpex-ffi-* + if-no-files-found: error + retention-days: 7 + + sign-and-release: + name: Sign and publish release + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all matrix artifacts + uses: actions/download-artifact@v4 + with: + path: dist + # merge-multiple flattens the per-target subdirs into one + # dist/ so the sign script and gh release upload see a + # flat layout. + merge-multiple: true + + - name: Generate aggregate SHA256SUMS + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + cd dist + # Concat all individual .sha256 files into one signed + # integrity manifest. The per-tarball .sha256 files stay + # as convenience companions, but the SHA256SUMS file is + # what auditors care about. + : > "cpex-ffi-${VERSION}-SHA256SUMS" + for f in cpex-ffi-*.tar.gz; do + if command -v sha256sum >/dev/null; then + sha256sum "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + else + shasum -a 256 "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + fi + done + echo "--- SHA256SUMS ---" + cat "cpex-ffi-${VERSION}-SHA256SUMS" + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign artifacts + env: + DIST_DIR: dist + run: bash scripts/release/sign-artifact.sh + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + # Auto-detect prerelease by suffix so dry-run tags + # (v0.0.0-ffi.test.1) land as prereleases and don't surface + # as "latest" on the repo's Releases page. + PRERELEASE_FLAG="" + if [[ "$TAG" == *-* ]]; then + PRERELEASE_FLAG="--prerelease" + fi + + # Idempotent: if the release exists, upload --clobber the + # new files; if it doesn't, create it with the tarballs + + # SHA256SUMS + sigs in one shot. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists; uploading artifacts with --clobber" + gh release upload "$TAG" dist/cpex-ffi-* --clobber + else + echo "Creating release $TAG" + gh release create "$TAG" \ + --title "$TAG" \ + --notes "Automated FFI artifact release. See crates/cpex-ffi/RELEASE.md for the schema and verify-and-consume recipe." \ + $PRERELEASE_FLAG \ + dist/cpex-ffi-* + fi diff --git a/.gitignore b/.gitignore index 35b98221..82f0d5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ FIXMEs logs/ *.log .sketchpad +.localtarget* # Fuzzing artifacts and reports reports/ @@ -201,13 +202,29 @@ celerybeat-schedule # Environments .env +.env.* +*.env +env.bak/ +env.back +env.bak .venv env/ venv/ ENV/ -env.bak/ venv.bak/ +# Loose credential / token files — defensive net against accidental +# `git add` of dev-captured real tokens. The `.env` patterns above +# already cover the canonical case. +bearertoken* +*.token +*.tokens +*credentials*.json +*credentials*.yaml +*credentials*.yml +apikey* +*.pem + # Spyder project settings .spyderproject .spyproject diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d9966a..5596e524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,36 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- APL (Attribute Policy Language) governance is now bundled into + `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers + the standard APL plugin/PDP factories (`validator/pii-scan`, + `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and + installs the APL config visitor on a manager. Call it after + `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use + `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds + the Cedarling-backed identity + PDP seams (off by default; the released + `.a` stays lean). +- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on + every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, + `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign + keyless signatures + SHA256 checksums; see + `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and- + consume recipe. +- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor + exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` + and panics on mismatch. Other language bindings must replicate the + check. + +### Changed + +- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` + extern C function and changed `cpex_load_config` to run registered + config visitors (it now calls `load_config_yaml` internally so `apl:` + blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped + in lockstep. + ## [0.1.1] - 2026-06-04 ### Added diff --git a/Cargo.lock b/Cargo.lock index e8149dbc..36aebe0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,829 +3,4681 @@ version = 4 [[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anyhow" -version = "1.0.102" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "arc-swap" -version = "1.9.1" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "rustversion", + "cfg-if", + "once_cell", + "version_check", + "zerocopy", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "proc-macro2", - "quote", - "syn", + "memchr", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "bitflags" -version = "2.11.0" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "anyhow" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +name = "apl-audit-logger" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +name = "apl-cedarling" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "cedar-policy", + "cedarling", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "cpex-core" +name = "apl-cmf" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "cpex-core", + "serde_json", + "tokio", +] + +[[package]] +name = "apl-core" version = "0.1.0" dependencies = [ - "arc-swap", "async-trait", + "cpex-orchestration", "futures", - "hashbrown 0.15.5", + "regex", "serde", "serde_json", "serde_yaml", - "thiserror", + "thiserror 2.0.18", "tokio", - "tokio-util", - "tracing", - "uuid", - "wildmatch", ] [[package]] -name = "cpex-demo-ffi" +name = "apl-cpex" version = "0.1.0" dependencies = [ + "apl-cmf", + "apl-core", "async-trait", + "chrono", "cpex-core", - "cpex-ffi", + "serde", "serde_json", + "serde_yaml", + "sha2 0.10.9", + "tokio", "tracing", ] [[package]] -name = "cpex-ffi" +name = "apl-delegator-biscuit" version = "0.1.0" dependencies = [ + "apl-core", "async-trait", + "biscuit-auth", + "chrono", "cpex-core", - "rmp-serde", + "hex", "serde", - "serde_bytes", "serde_json", + "serde_yaml", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] -name = "cpex-sdk" +name = "apl-delegator-oauth" version = "0.1.0" dependencies = [ + "apl-core", "async-trait", + "chrono", "cpex-core", + "mockito", + "reqwest 0.12.28", "serde", "serde_json", + "serde_urlencoded", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "zeroize", ] [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +name = "apl-identity-jwt" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "base64 0.22.1", + "chrono", + "cpex-core", + "futures", + "jsonwebtoken 9.3.1", + "mockito", + "rand 0.8.6", + "reqwest 0.12.28", + "rsa", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +name = "apl-pdp-cedar-direct" +version = "0.1.0" dependencies = [ - "libc", - "windows-sys", + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cedar-policy", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +name = "apl-pii-scanner" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "futures" -version = "0.3.32" +name = "ar_archive_writer" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "object", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "arc-swap" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ - "futures-core", - "futures-sink", + "rustversion", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "arraydeque" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] -name = "futures-executor" -version = "0.3.32" +name = "arrayvec" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "term", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "futures-task" -version = "0.3.32" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "futures-util" -version = "0.3.32" +name = "aws-lc-rs" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "aws-lc-sys" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "cc", + "cmake", + "dunce", + "fs_extra", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "hashbrown" -version = "0.17.0" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] -name = "heck" -version = "0.5.0" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] -name = "id-arena" -version = "2.3.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "indexmap" -version = "2.14.0" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "biscuit-auth" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5884fc86b3e21f5649ef4326e17ef729b3096e6502deaf13db7b7fb05bb992b" dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "base64 0.13.1", + "biscuit-parser", + "biscuit-quote", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "getrandom 0.2.17", + "hex", + "nom", + "p256", + "pkcs8 0.9.0", + "prost", + "prost-types", + "rand 0.8.6", + "rand_core 0.6.4", + "regex", + "serde_json", + "sha2 0.9.9", + "thiserror 1.0.69", + "time", + "zeroize", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "biscuit-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7cafdbc8c30e1f0fb87df7161bec77f6f00da652cc33f102b0f95bd1cbc0fa" +dependencies = [ + "hex", + "nom", + "proc-macro2", + "quote", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "biscuit-quote" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d2332c742a07a846f1fb2760e58a0ee60f2bc30987046fcea816b40630335a" +dependencies = [ + "biscuit-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedar-policy" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674ca6ef6e44a1e29e6c07f6b2ab7e6913938d6049204d48e7849703d02443ce" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools 0.14.0", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.18", +] + +[[package]] +name = "cedar-policy-core" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df781c108240c3b3778c586c6a1b234355f1a2f9d35e08630b63b2590c59dbb" +dependencies = [ + "chrono", + "educe", + "either", + "itertools 0.14.0", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.18", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e293607563253e249d19cf4d36ac05821955170a63cf6d65deb4db60138b192e" +dependencies = [ + "cedar-policy-core", + "itertools 0.14.0", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + +[[package]] +name = "cedarling" +version = "2.1.0" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "ahash", + "async-trait", + "base64 0.22.1", + "cedar-policy", + "cedar-policy-core", + "chrono", + "config", + "derive_more", + "flate2", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.2", + "gloo-timers", + "hdrhistogram", + "http_utils", + "jsonwebtoken 10.4.0", + "rand 0.10.1", + "reqwest 0.13.3", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "smol_str", + "sparkv", + "strum", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "typed-builder", + "url", + "uuid7", + "vfs", + "wasm-bindgen-futures", + "web-sys", + "zip", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "config" +version = "0.15.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpex-core" +version = "0.1.0" +dependencies = [ + "arc-swap", + "async-trait", + "chrono", + "cpex-orchestration", + "futures", + "hashbrown 0.15.5", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", + "zeroize", +] + +[[package]] +name = "cpex-demo-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-ffi", + "serde_json", + "tracing", +] + +[[package]] +name = "cpex-ffi" +version = "0.1.0" +dependencies = [ + "apl-audit-logger", + "apl-cedarling", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pii-scanner", + "async-trait", + "cpex-core", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-orchestration" +version = "0.1.0" +dependencies = [ + "futures", + "tokio", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fstr" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b8f793a77bb6d48059953a3e9820fd860d19a9bed8164ed3572eb1981ec8aa" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.7", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_utils" +version = "0.1.0" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "reqwest 0.13.3", + "serde", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rust2" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9ceaec84b54518262de7cf06b8b43e83c808349960f1610b21b0bfc9640f20" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +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", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "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-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width 0.2.2", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b670f45da57fb8542ebdbb6105a925fe571b67f9e7ed9f47a06a84e72b4e7cc" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0a014229361011dc8e69c8a1ec6c2e8d0f2af7c91e3ea3f5b2170298461e68" +dependencies = [ + "bytes", + "prost", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[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 = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-literal-escaper" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sparkv" +version = "0.1.1" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "chrono", + "thiserror 2.0.18", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "der 0.6.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] -name = "js-sys" -version = "0.3.95" +name = "unicode-security" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" dependencies = [ - "once_cell", - "wasm-bindgen", + "unicode-normalization", + "unicode-script", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] -name = "libc" -version = "0.2.184" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "lock_api" -version = "0.4.14" +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "log" -version = "0.4.29" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "memchr" -version = "2.8.0" +name = "unsafe-libyaml" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "mio" -version = "1.2.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "num-traits" -version = "0.2.19" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "autocfg", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "uuid" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "lock_api", - "parking_lot_core", + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "uuid7" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "f14c93e6dd46ded457afc647964ac685427f9f001815d07ba30398cb79d9c9ce" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "fstr", + "rand_core 0.10.1", + "rand_core 0.6.4", + "serde", + "uuid", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "prettyplease" -version = "0.2.37" +name = "vfs" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "9e723b9e1c02a3cf9f9d0de6a4ddb8cdc1df859078902fe0ae0589d615711ae6" dependencies = [ - "proc-macro2", - "syn", + "filetime", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "unicode-ident", + "same-file", + "winapi-util", ] [[package]] -name = "quote" -version = "1.0.45" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "proc-macro2", + "try-lock", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "bitflags", + "wit-bindgen", ] [[package]] -name = "rmp" -version = "0.8.15" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "num-traits", + "wit-bindgen", ] [[package]] -name = "rmp-serde" -version = "1.3.1" +name = "wasm-bindgen" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ - "rmp", - "serde", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "wasm-bindgen-futures" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "wasm-bindgen-macro" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "wasm-bindgen-macro-support" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] [[package]] -name = "semver" -version = "1.0.28" +name = "wasm-bindgen-shared" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] [[package]] -name = "serde" -version = "1.0.228" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "serde_core", - "serde_derive", + "leb128fmt", + "wasmparser", ] [[package]] -name = "serde_bytes" -version = "0.11.19" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "serde", - "serde_core", + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "serde_derive", + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "web-sys" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "webpki-root-certs" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "rustls-pki-types", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "webpki-roots" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ - "errno", - "libc", + "rustls-pki-types", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wildmatch" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] -name = "smallvec" -version = "1.15.1" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "libc", - "windows-sys", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "syn" -version = "2.0.117" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-link", ] [[package]] -name = "tokio" -version = "1.51.1" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "windows-link", ] [[package]] -name = "tokio-macros" -version = "2.7.0" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-targets 0.52.6", ] [[package]] -name = "tokio-util" -version = "0.7.18" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", + "windows-targets 0.53.5", ] [[package]] -name = "tracing" -version = "0.1.44" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "windows-link", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "once_cell", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "uuid" -version = "1.23.0" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "windows_i686_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "wasm-bindgen" -version = "0.2.118" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] -name = "wasm-bindgen-macro" -version = "0.2.118" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.118" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] -name = "wasm-bindgen-shared" -version = "0.2.118" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" -dependencies = [ - "unicode-ident", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "wasmparser" -version = "0.244.0" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] -name = "wildmatch" -version = "2.6.1" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ - "windows-link", + "memchr", ] [[package]] @@ -856,9 +4708,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -874,7 +4726,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -887,7 +4739,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -906,7 +4758,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -916,8 +4768,208 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72" +dependencies = [ + "bzip2", + "crc32fast", + "deflate64", + "flate2", + "indexmap 2.14.0", + "lzma-rust2", + "memchr", + "ppmd-rust", + "time", + "typed-path", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 62f40dac..da736d6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,46 @@ resolver = "2" members = [ "crates/cpex-core", + "crates/cpex-orchestration", "crates/cpex-sdk", "crates/cpex-ffi", + "crates/apl-core", + "crates/apl-cmf", + "crates/apl-cpex", + "crates/apl-pdp-cedar-direct", + "crates/apl-cedarling", + "crates/apl-identity-jwt", + "crates/apl-delegator-oauth", + "crates/apl-delegator-biscuit", + "crates/apl-pii-scanner", + "crates/apl-audit-logger", + "examples/go-demo/ffi", +] + +# `default-members` controls what `cargo build` / `cargo test` (with no +# `-p` or `--workspace` flag) picks up. Cedarling integration crates +# pull ~200 transitive deps (jsonwebtoken, reqwest, sparkv, datalogic-rs, +# flate2, etc.) and slow default builds significantly — excluding them +# from default-members keeps everyday iteration fast. +# +# To exercise Cedarling crates: +# cargo build --workspace # all members +# cargo build -p apl-cedarling # just this one +# cargo test --workspace # full sweep (CI) +default-members = [ + "crates/cpex-core", + "crates/cpex-orchestration", + "crates/cpex-sdk", + "crates/cpex-ffi", + "crates/apl-core", + "crates/apl-cmf", + "crates/apl-cpex", + "crates/apl-pdp-cedar-direct", + "crates/apl-identity-jwt", + "crates/apl-delegator-oauth", + "crates/apl-delegator-biscuit", + "crates/apl-pii-scanner", + "crates/apl-audit-logger", "examples/go-demo/ffi", ] @@ -37,3 +75,5 @@ arc-swap = "1.7" wildmatch = "2" rmp-serde = "1" serde_bytes = "0.11" +chrono = { version = "0.4", features = ["serde"] } +regex = "1" diff --git a/crates/apl-audit-logger/Cargo.toml b/crates/apl-audit-logger/Cargo.toml new file mode 100644 index 00000000..ad729e06 --- /dev/null +++ b/crates/apl-audit-logger/Cargo.toml @@ -0,0 +1,29 @@ +# Location: ./crates/apl-audit-logger/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-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 = "apl-audit-logger" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-audit-logger/src/config.rs b/crates/apl-audit-logger/src/config.rs new file mode 100644 index 00000000..168750b1 --- /dev/null +++ b/crates/apl-audit-logger/src/config.rs @@ -0,0 +1,33 @@ +// Location: ./crates/apl-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/crates/apl-audit-logger/src/factory.rs b/crates/apl-audit-logger/src/factory.rs new file mode 100644 index 00000000..05eb90fd --- /dev/null +++ b/crates/apl-audit-logger/src/factory.rs @@ -0,0 +1,55 @@ +// Location: ./crates/apl-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 '{}' (apl-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/crates/apl-audit-logger/src/lib.rs b/crates/apl-audit-logger/src/lib.rs new file mode 100644 index 00000000..5671c372 --- /dev/null +++ b/crates/apl-audit-logger/src/lib.rs @@ -0,0 +1,40 @@ +// Location: ./crates/apl-audit-logger/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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/crates/apl-audit-logger/src/logger.rs b/crates/apl-audit-logger/src/logger.rs new file mode 100644 index 00000000..7f6404d7 --- /dev/null +++ b/crates/apl-audit-logger/src/logger.rs @@ -0,0 +1,254 @@ +// Location: ./crates/apl-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 '{}' (apl-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/crates/apl-cedarling/Cargo.toml b/crates/apl-cedarling/Cargo.toml new file mode 100644 index 00000000..b0c0dc8a --- /dev/null +++ b/crates/apl-cedarling/Cargo.toml @@ -0,0 +1,64 @@ +# Location: ./crates/apl-cedarling/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cedarling — Cedarling-backed IdentityResolveHandler and +# PdpResolver implementations. +# +# Two modules in one crate because they share the same heavy dep +# (cedarling) and almost always run in the same deployment — an +# operator using Cedarling for identity resolution invariably also +# wants it for policy decisions, and the two consume the same +# `Cedarling` instance + policy store. +# +# # Why this crate isn't in default-members +# +# Cedarling pulls ~200 transitive dependencies (jsonwebtoken, reqwest, +# sparkv, datalogic-rs, flate2, regex, ahash, time, vfs, zip, …). To +# keep `cargo build` at the workspace root fast for the majority of +# iteration, the workspace excludes this crate from `default-members`. +# Build it explicitly with `cargo build -p apl-cedarling` or with +# `cargo build --workspace` for the full sweep. + +[package] +name = "apl-cedarling" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# Cedarling lives in the Janssen Project monorepo at the path +# `jans-cedarling/cedarling/` within that repo. We pin to a release +# tag rather than a branch so the dep tree stays reproducible across +# checkouts — bump the tag deliberately when we want a new version. +# +# `package = "cedarling"` tells Cargo which named crate to pick from +# the monorepo's multiple workspaces. `default-features = false` +# disables `grpc` (tonic+prost for Lock Server); Lock Server +# integration lands behind its own feature flag if/when we wire it. +# +# First build for new collaborators clones the Janssen monorepo (~200 +# transitive deps + cedarling's vendored workspace). Cached in +# ~/.cargo/git/ afterward. +cedarling = { git = "https://github.com/JanssenProject/jans", tag = "v2.1.0", package = "cedarling", default-features = false } + +# cedar-policy is a direct dep so we can name `cedar_policy::Decision` +# etc. in the resolver. Caret spec lets Cargo dedup to the same +# version Cedarling pulls (currently 4.11.0 transitively). +cedar-policy = "4" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-cedarling/src/error.rs b/crates/apl-cedarling/src/error.rs new file mode 100644 index 00000000..d81fab69 --- /dev/null +++ b/crates/apl-cedarling/src/error.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-cedarling/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build-time errors for constructing Cedarling-backed resolvers and +// handlers. Runtime errors flow through `PluginViolation` (for +// hook handlers) or `PdpError::Dispatch` (for the PDP path) — same +// pattern as `apl-pdp-cedar-direct`. + +use thiserror::Error; + +/// Errors that can occur while constructing a Cedarling-backed +/// resolver or handler from config. +#[non_exhaustive] +#[derive(Debug, Error)] +pub enum CedarlingPluginError { + /// The policy store file/URL couldn't be loaded. + #[error("failed to load policy store: {0}")] + PolicyStoreLoad(String), + + /// The bootstrap config was malformed or missing required fields. + #[error("invalid Cedarling bootstrap config: {0}")] + BootstrapConfig(String), + + /// Cedarling itself failed to initialize (JWKS unreachable, + /// schema validation failed, etc.). + #[error("Cedarling initialization failed: {0}")] + Init(String), +} diff --git a/crates/apl-cedarling/src/identity/mod.rs b/crates/apl-cedarling/src/identity/mod.rs new file mode 100644 index 00000000..1d7fdd8c --- /dev/null +++ b/crates/apl-cedarling/src/identity/mod.rs @@ -0,0 +1,31 @@ +// Location: ./crates/apl-cedarling/src/identity/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Cedarling-backed IdentityResolveHandler. +// +// Sub-step A scope: stub module. Actual implementation lands in +// sub-step B. +// +// # Planned shape +// +// ```ignore +// pub struct CedarlingIdentityResolver { +// cedarling: Arc, +// // optional: which sentinel action to use for identity-only +// // validation when no real policy decision is being made +// identity_action: String, +// } +// +// impl HookHandler for CedarlingIdentityResolver { +// async fn handle(&self, payload: &IdentityPayload, ...) -> ... { +// // Build TokenInputs from payload.raw_token() + headers +// // Call cedarling.authorize_multi_issuer with sentinel action +// // If decision is deny -> PluginResult::deny(violation) +// // If allow -> extract validated entities, map to +// // SubjectExtension / ClientExtension / WorkloadIdentity +// // and return modified payload +// } +// } +// ``` diff --git a/crates/apl-cedarling/src/lib.rs b/crates/apl-cedarling/src/lib.rs new file mode 100644 index 00000000..fb2ae21b --- /dev/null +++ b/crates/apl-cedarling/src/lib.rs @@ -0,0 +1,48 @@ +// Location: ./crates/apl-cedarling/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cedarling — Cedarling-backed plugins for APL's two adjacent +// auth seams: +// +// * [`identity`] — `IdentityResolveHandler` that validates inbound +// JWTs through Cedarling and maps validated tokens into +// `SubjectExtension` / `ClientExtension`. Optionally runs an +// advisory Cedar policy check ("is this principal allowed at all") +// during the validation pass. +// * [`pdp`] — `PdpResolver` for `cedar:(...)` steps in APL routes. +// Mirrors the cedar-direct resolver but uses Cedarling's policy +// store loading + (eventually) Lock Server hooks instead of +// in-process `cedar-policy::PolicySet`. +// +// Both modules share a single `Cedarling` instance constructed from +// the same bootstrap config — operators using one almost always want +// the other, and double-loading the policy store / JWKS would be +// wasteful. +// +// # When to reach for this crate vs alternatives +// +// - **`apl-pdp-cedar-direct`** — simpler, ~5 transitive deps, +// policies as inline text. Use for tests, dev, or deployments +// that don't need policy-store signing / centralized management. +// - **`apl-identity-jwt`** (future) — JWT validation via the +// `jsonwebtoken` crate, no Cedar coupling, ~5 transitive deps. +// Use when you want lightweight identity without policy-driven +// identity decisions. +// - **`apl-cedarling`** (this crate) — heavy dep tree but gives you +// signed policy stores, Cedar-driven identity decisions, and +// (future) Lock Server fleet management. Use for production +// deployments with centralized policy management. +// +// # Sub-step A scope +// +// Module skeletons + crate wiring only. No actual Cedarling calls. +// Existence of this crate validates the dep-resolution cost honestly +// before we commit to the implementation. + +pub mod error; +pub mod identity; +pub mod pdp; + +pub use error::CedarlingPluginError; diff --git a/crates/apl-cedarling/src/pdp/mod.rs b/crates/apl-cedarling/src/pdp/mod.rs new file mode 100644 index 00000000..dad4ac3d --- /dev/null +++ b/crates/apl-cedarling/src/pdp/mod.rs @@ -0,0 +1,10 @@ +// Location: ./crates/apl-cedarling/src/pdp/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Cedarling-backed PdpResolver. + +pub mod resolver; + +pub use resolver::CedarlingPdpResolver; diff --git a/crates/apl-cedarling/src/pdp/resolver.rs b/crates/apl-cedarling/src/pdp/resolver.rs new file mode 100644 index 00000000..076f2854 --- /dev/null +++ b/crates/apl-cedarling/src/pdp/resolver.rs @@ -0,0 +1,374 @@ +// Location: ./crates/apl-cedarling/src/pdp/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarlingPdpResolver` — `PdpResolver` impl that delegates Cedar +// policy evaluation to a Cedarling instance. +// +// # Why Cedarling here instead of `apl-pdp-cedar-direct` +// +// Both call the same Cedar evaluator under the hood. The difference +// is the policy-store loading + management layer Cedarling provides: +// signed policy bundles, multi-policy stores keyed by ID, optional +// Lock Server integration for fleet-wide updates. Deployments that +// don't need any of that should reach for `apl-pdp-cedar-direct` +// instead — it's ~5 deps vs ~200. +// +// # Construction +// +// This resolver does NOT construct its own Cedarling instance. +// Cedarling holds shared state (JWT keys, entity store cache, +// optional Lock Server connection) that an entire deployment +// typically wants to share between identity resolution and PDP +// evaluation. The host builds one `Arc` at startup and +// hands the same handle to both this resolver and the +// (forthcoming) `CedarlingIdentityResolver`. +// +// # `authorize_unsigned` +// +// We use Cedarling's `authorize_unsigned` rather than +// `authorize_multi_issuer`. Reasoning: +// * APL has already done identity resolution by the time `cedar:` +// policy steps run — `Extensions.security.subject` / +// `.client` / `.caller_workload` are populated. +// * We build the principal entity from the `AttributeBag` directly, +// bypassing Cedarling's JWT-validation path entirely. +// * No sentinel-action workaround needed (the one we discussed for +// using `authorize_multi_issuer` purely for identity). + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use cedarling::{CedarEntityMapping, Cedarling, EntityData, RequestUnsigned}; +use serde_json::{json, Map, Value}; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +/// `PdpResolver` that dispatches policy decisions to a Cedarling +/// instance. See module docs for when to prefer this over +/// `apl-pdp-cedar-direct`. +pub struct CedarlingPdpResolver { + /// Shared Cedarling instance — built once at host startup, + /// passed to both this resolver and the identity handler. + cedarling: Arc, + + /// The dialect this resolver registers under in the `PdpRouter`. + /// Defaults to `PdpDialect::Cedarling` (a distinct variant from + /// `Cedar`) so both `apl-pdp-cedar-direct` and this crate can + /// coexist in the same router and routes target each explicitly + /// via `cedar:(...)` vs `cedarling:(...)` step keys. + dialect: PdpDialect, + + /// Optional namespace prefix prepended to entity types built + /// from the bag (`"User"` → `"Jans::User"`). Matches the + /// `apl-pdp-cedar-direct` ergonomics; deployments with + /// namespaced schemas set this once at startup. + entity_namespace: Option, +} + +impl CedarlingPdpResolver { + /// Build a resolver around a pre-constructed Cedarling instance. + /// Cedarling construction is async and config-heavy + /// (`BootstrapConfig`, policy store loading); doing it inside + /// the resolver would force every call site into an async + /// context. The host owns the lifecycle. + pub fn new(cedarling: Arc) -> Self { + Self { + cedarling, + dialect: PdpDialect::Cedarling, + entity_namespace: None, + } + } + + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { + self.entity_namespace = Some(namespace.into()); + self + } +} + +#[async_trait] +impl PdpResolver for CedarlingPdpResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + let map = call.args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar:() args must be a mapping with action/resource keys" + .to_string(), + ) + })?; + + let action = yaml_string(map, "action").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.action missing or not a string".into()) + })?; + + let resource_value = map + .get(serde_yaml::Value::String("resource".to_string())) + .ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource missing".into()) + })?; + let resource = build_resource_entity_data(resource_value)?; + + let principal = + build_principal_entity_data(bag, self.entity_namespace.as_deref())?; + + let context = map + .get(serde_yaml::Value::String("context".to_string())) + .map(|v| serde_json::to_value(v)) + .transpose() + .map_err(|e| { + PdpError::Dispatch(format!( + "cedarling: cedar:() args.context not JSON-representable: {e}" + )) + })? + .unwrap_or(Value::Object(Map::new())); + + let request = RequestUnsigned { + principal: Some(principal), + action, + resource, + context, + }; + + let result = self.cedarling.authorize_unsigned(request).await.map_err(|e| { + PdpError::Dispatch(format!("cedarling: authorize_unsigned failed: {e}")) + })?; + + Ok(translate_authorize_result(&result)) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Build the Cedarling principal entity from the attribute bag. Same +/// claim shape as `apl-pdp-cedar-direct`: +/// +/// * `subject.id` → entity id (required) +/// * `subject.type` → entity type ("User" default) +/// * `role.=true` → attrs.roles : Set +/// * `perm.=true` → attrs.permissions : Set +/// * `claim.=v` → attrs.claims. = v +/// * `subject.teams` → attrs.teams : Set +/// +/// Returns `EntityData` (Cedarling's JSON-shaped entity carrier), +/// which Cedarling converts internally to a `cedar_policy::Entity`. +fn build_principal_entity_data( + bag: &AttributeBag, + namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| { + PdpError::Dispatch( + "cedarling: 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, namespace); + + let mut attributes: HashMap = HashMap::new(); + attributes.insert("id".to_string(), json!(id)); + attributes.insert("type".to_string(), json!(kind)); + + let roles = collect_prefixed_bools(bag, "role."); + attributes.insert("roles".to_string(), json!(roles)); + + let permissions = collect_prefixed_bools(bag, "perm."); + attributes.insert("permissions".to_string(), json!(permissions)); + + let teams: Vec = bag + .get_string_set("subject.teams") + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + attributes.insert("teams".to_string(), json!(teams)); + + let claims = collect_claims(bag); + attributes.insert("claims".to_string(), Value::Object(claims)); + + Ok(EntityData { + cedar_mapping: CedarEntityMapping { + entity_type, + id, + }, + attributes, + }) +} + +/// Build the resource entity from the policy author's `args.resource` +/// block: +/// +/// ```yaml +/// resource: +/// type: Document # required +/// id: doc-42 # required +/// attributes: # optional +/// classification: internal +/// ``` +fn build_resource_entity_data( + resource_args: &serde_yaml::Value, +) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar:() args.resource must be a mapping".to_string(), + ) + })?; + let entity_type = yaml_string(map, "type").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource.type missing".to_string()) + })?; + let id = yaml_string(map, "id").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource.id missing".to_string()) + })?; + + let mut attributes: HashMap = HashMap::new(); + if let Some(attrs_value) = map.get(serde_yaml::Value::String("attributes".to_string())) + { + let attrs_json: Value = serde_json::to_value(attrs_value).map_err(|e| { + PdpError::Dispatch(format!( + "cedarling: cedar:() args.resource.attributes not JSON-representable: {e}" + )) + })?; + if let Value::Object(map) = attrs_json { + for (k, v) in map { + attributes.insert(k, v); + } + } + } + + Ok(EntityData { + cedar_mapping: CedarEntityMapping { + entity_type, + id, + }, + attributes, + }) +} + +/// Translate Cedarling's `AuthorizeResult` into APL's `PdpDecision`. +/// Mirrors `apl-pdp-cedar-direct`'s decision-translation logic since +/// both crates ultimately read the same `cedar_policy::Response`. +/// Fail-closed on diagnostic errors. +fn translate_authorize_result(result: &cedarling::AuthorizeResult) -> PdpDecision { + use cedar_policy::Decision as CedarDecision; + let response = &result.response; + let diagnostics = response.diagnostics(); + + let firing_policies: Vec = diagnostics + .reason() + .map(|pid| pid.to_string()) + .collect(); + + let errors: Vec = diagnostics.errors().map(|e| e.to_string()).collect(); + + // Cedar evaluation errors → fail-closed deny. Same rule as + // `apl-pdp-cedar-direct`: any runtime error during evaluation + // produces an untrustworthy decision, so we override to deny. + 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 => { + let reason = if firing_policies.is_empty() { + "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, + } +} + +// ----- Small helpers, mirror cedar-direct ----- + +fn qualify_type(bare: &str, namespace: Option<&str>) -> String { + match namespace { + Some(ns) if !ns.is_empty() => format!("{ns}::{bare}"), + _ => bare.to_string(), + } +} + +fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { + use std::collections::HashSet; + 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 +} + +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/crates/apl-cedarling/tests/pdp_basic.rs b/crates/apl-cedarling/tests/pdp_basic.rs new file mode 100644 index 00000000..0fee5f07 --- /dev/null +++ b/crates/apl-cedarling/tests/pdp_basic.rs @@ -0,0 +1,166 @@ +// Location: ./crates/apl-cedarling/tests/pdp_basic.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Basic e2e for `CedarlingPdpResolver`: build a Cedarling instance +// against an inline policy store, dispatch a `cedar:` call through +// the resolver, assert the allow/deny path. +// +// This test exercises the full Cedarling stack — bootstrap config +// parsing, policy store loading, schema validation, Cedar evaluation, +// response translation. The `policy-store_no_trusted_issuers.yaml` +// pattern (no trusted JWT issuers configured) is what makes +// `authorize_unsigned` viable for us — Cedarling skips its JWT +// validation path entirely when there are no trusted issuers, so we +// can drive policy decisions purely from the bag-built entities. + +use std::sync::Arc; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; + +use apl_cedarling::pdp::CedarlingPdpResolver; +use cedarling::{BootstrapConfig, Cedarling, PolicyStoreSource}; + +/// Minimal policy store: one permit policy that fires for +/// `Action::"read"` against any `Document` when the principal +/// carries `roles` containing "reader". The schema declares a +/// `Jans` namespace so policy IDs / entities resolve cleanly. +const POLICY_STORE_YAML: &str = r#" +cedar_version: v4.0.0 +policy_stores: + test-store-001: + cedar_version: v4.0.0 + name: "test" + policies: + 1: + description: reader-only read permit + creation_date: "2026-05-21T00:00:00.000000" + policy_content: + encoding: none + content_type: cedar + body: |- + permit( + principal, + action == Jans::Action::"read", + resource + )when{ + principal.roles.contains("reader") + }; + schema: + encoding: none + content_type: cedar + body: |- + namespace Jans { + entity Document = { "classification": String }; + entity User = { "roles": Set }; + action "read" appliesTo { + principal: [User], + resource: [Document], + context: {} + }; + } +"#; + +/// Build a Cedarling instance configured with the test policy store +/// and no trusted JWT issuers — so `authorize_unsigned` is the right +/// path (no token validation involved). +async fn build_cedarling() -> Arc { + let mut config = BootstrapConfig::default(); + config.application_name = "apl-cedarling-test".to_string(); + config.policy_store_config.source = + PolicyStoreSource::Yaml(POLICY_STORE_YAML.to_string()); + let cedarling = Cedarling::new(&config) + .await + .expect("Cedarling::new should succeed with valid config"); + Arc::new(cedarling) +} + +fn alice_with_reader_role() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "User"); + bag.set("role.reader", true); + bag +} + +fn bob_no_roles() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "bob"); + bag.set("subject.type", "User"); + bag +} + +fn read_doc_call() -> PdpCall { + PdpCall { + // Route YAML `cedarling:(...)` produces this dialect. + // `apl-pdp-cedar-direct` registers under `PdpDialect::Cedar` + // so both resolvers can coexist in one PdpRouter. + dialect: PdpDialect::Cedarling, + args: serde_yaml::from_str( + r#" +action: 'Jans::Action::"read"' +resource: + type: Jans::Document + id: doc-42 + attributes: + classification: internal +"#, + ) + .unwrap(), + } +} + +#[tokio::test] +async fn reader_role_allows() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling) + .with_entity_namespace("Jans"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_with_reader_role()) + .await + .expect("evaluate should succeed"); + assert!( + matches!(decision.decision, Decision::Allow), + "alice with role.reader should be allowed: got {:?}", + decision.decision, + ); +} + +#[tokio::test] +async fn missing_role_default_denies() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling) + .with_entity_namespace("Jans"); + let decision = resolver + .evaluate(&read_doc_call(), &bob_no_roles()) + .await + .expect("evaluate should succeed"); + match decision.decision { + Decision::Deny { rule_source, .. } => { + // No permit fired → cedar.default_deny sentinel. + assert_eq!(rule_source, "cedar.default_deny"); + } + Decision::Allow => panic!("bob without reader role should be denied"), + } +} + +#[tokio::test] +async fn missing_subject_id_errors_clearly() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling); + // Bag with no subject.id at all — resolver should fail + // construction of the principal entity with a clear error. + let bag = AttributeBag::new(); + let err = resolver + .evaluate(&read_doc_call(), &bag) + .await + .expect_err("missing subject.id should error"); + let msg = format!("{err:?}"); + assert!( + msg.contains("subject.id"), + "error should call out the missing key, got: {msg}", + ); +} diff --git a/crates/apl-cmf/Cargo.toml b/crates/apl-cmf/Cargo.toml new file mode 100644 index 00000000..141b4ea2 --- /dev/null +++ b/crates/apl-cmf/Cargo.toml @@ -0,0 +1,24 @@ +# 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 + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/apl-cmf/src/agent.rs b/crates/apl-cmf/src/agent.rs new file mode 100644 index 00000000..1af89e19 --- /dev/null +++ b/crates/apl-cmf/src/agent.rs @@ -0,0 +1,68 @@ +// 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 new file mode 100644 index 00000000..f69543fb --- /dev/null +++ b/crates/apl-cmf/src/capability_namespaces.rs @@ -0,0 +1,312 @@ +// 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] = &[ + // ----- Subject identity ----- + 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, + ], + }, + + // ----- Security extension (non-subject) ----- + 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], + }, + + // ----- Credential material — payload-only, no bag prefixes ----- + 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: &[], + }, + + // ----- Delegation chain ----- + CapabilityEntry { + name: CAP_READ_DELEGATION, + prefixes: &[BAG_DELEGATION_PREFIX, BAG_DELEGATED], + }, + + // ----- Other extensions ----- + 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, + ], + }, + 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() { + // Write caps don't expose bag-readable state. + 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 new file mode 100644 index 00000000..6a8bab82 --- /dev/null +++ b/crates/apl-cmf/src/completion.rs @@ -0,0 +1,76 @@ +// 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 new file mode 100644 index 00000000..276fb4a6 --- /dev/null +++ b/crates/apl-cmf/src/constants.rs @@ -0,0 +1,113 @@ +// 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. + +// ===================================================================== +// Capability names — must match cpex-core's vocabulary +// ===================================================================== + +// ----- Subject identity (read) ----- +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"; + +// ----- Security extension (non-subject) ----- +pub const CAP_READ_LABELS: &str = "read_labels"; +pub const CAP_READ_CLIENT: &str = "read_client"; +pub const CAP_READ_WORKLOAD: &str = "read_workload"; + +// ----- Credential material — payload-only, no bag prefixes ----- +pub const CAP_READ_INBOUND_CREDENTIALS: &str = "read_inbound_credentials"; +pub const CAP_READ_DELEGATED_TOKENS: &str = "read_delegated_tokens"; + +// ----- Per-extension reads ----- +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"; + +// ----- Write tokens — don't unlock bag attributes ----- +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`). +// ===================================================================== + +// ----- Subject ----- +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."; + +// ----- Client + workload ----- +pub const BAG_CLIENT_PREFIX: &str = "client."; +pub const BAG_WORKLOAD_PREFIX: &str = "workload."; +pub const BAG_CALLER_WORKLOAD_PREFIX: &str = "caller_workload."; + +// ----- Delegation ----- +pub const BAG_DELEGATION_PREFIX: &str = "delegation."; +pub const BAG_DELEGATED: &str = "delegated"; + +// ----- Other extensions ----- +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."; +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 new file mode 100644 index 00000000..a933fbc3 --- /dev/null +++ b/crates/apl-cmf/src/custom.rs @@ -0,0 +1,42 @@ +// 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 new file mode 100644 index 00000000..50d8f6b9 --- /dev/null +++ b/crates/apl-cmf/src/delegation.rs @@ -0,0 +1,88 @@ +// 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 new file mode 100644 index 00000000..6c650aca --- /dev/null +++ b/crates/apl-cmf/src/extensions_bridge.rs @@ -0,0 +1,94 @@ +// 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 new file mode 100644 index 00000000..ccd39e55 --- /dev/null +++ b/crates/apl-cmf/src/framework.rs @@ -0,0 +1,55 @@ +// 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 new file mode 100644 index 00000000..60d84565 --- /dev/null +++ b/crates/apl-cmf/src/http.rs @@ -0,0 +1,45 @@ +// 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.request_headers. : String (lowercased name) +// http.response_headers. : String (lowercased name) + +use apl_core::AttributeBag; +use cpex_core::extensions::HttpExtension; + +pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { + 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 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 new file mode 100644 index 00000000..dcbeda91 --- /dev/null +++ b/crates/apl-cmf/src/lib.rs @@ -0,0 +1,137 @@ +// 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 (see docs/specs/apl-design.md §4): +// +// 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.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_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 + } + + /// 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 new file mode 100644 index 00000000..0dbe3332 --- /dev/null +++ b/crates/apl-cmf/src/llm.rs @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000..9327dd1b --- /dev/null +++ b/crates/apl-cmf/src/mcp.rs @@ -0,0 +1,92 @@ +// 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 new file mode 100644 index 00000000..7f1ba17a --- /dev/null +++ b/crates/apl-cmf/src/meta.rs @@ -0,0 +1,57 @@ +// 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 new file mode 100644 index 00000000..11a22f7d --- /dev/null +++ b/crates/apl-cmf/src/payload.rs @@ -0,0 +1,150 @@ +// 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); +} + +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)); + } +} diff --git a/crates/apl-cmf/src/provenance.rs b/crates/apl-cmf/src/provenance.rs new file mode 100644 index 00000000..27ddba07 --- /dev/null +++ b/crates/apl-cmf/src/provenance.rs @@ -0,0 +1,38 @@ +// 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 new file mode 100644 index 00000000..7801b71f --- /dev/null +++ b/crates/apl-cmf/src/request.rs @@ -0,0 +1,54 @@ +// 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 new file mode 100644 index 00000000..f23f9381 --- /dev/null +++ b/crates/apl-cmf/src/security.rs @@ -0,0 +1,525 @@ +// 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) { + // ----- Subject (caller identity) ----- + 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); + } + } + + // ----- Client (OAuth application identity) ----- + if let Some(client) = &sec.client { + extract_client(client, bag); + } + + // ----- Inbound caller's attested workload identity ----- + if let Some(caller) = &sec.caller_workload { + extract_workload("caller_workload", caller, bag); + } + + // ----- Our own attested workload identity (outbound) ----- + if let Some(this_w) = &sec.this_workload { + extract_workload("this_workload", this_w, bag); + } + + // ----- Other security fields ----- + 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)); + } + + // ----------------------------------------------------------------- + // Client (OAuth application identity) bag namespace + // ----------------------------------------------------------------- + + 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")); + } + + // ----------------------------------------------------------------- + // Workload (extract_workload helper — both prefixes) + // ----------------------------------------------------------------- + + 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); + } + + // ----------------------------------------------------------------- + // extract_security orchestrates all four identity slots + // ----------------------------------------------------------------- + + #[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 new file mode 100644 index 00000000..cdfa1a11 --- /dev/null +++ b/crates/apl-cmf/tests/end_to_end.rs @@ -0,0 +1,275 @@ +// 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, + NoopDelegationInvoker, 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 (E3.2). 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) +} + +// HR route from unified-config-proposal.md §Example 1. +const HR_ROUTE_YAML: &str = r#" +routes: + get_employee: + args: + employee_id: "str" + policy: + - "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()).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()).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()).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: + policy: + - "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()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy"), "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()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); +} diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml new file mode 100644 index 00000000..b04b0951 --- /dev/null +++ b/crates/apl-core/Cargo.toml @@ -0,0 +1,32 @@ +# 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 — Attribute Policy Language core (compiler + evaluator)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[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 = { path = "../cpex-orchestration" } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs new file mode 100644 index 00000000..17bac0e0 --- /dev/null +++ b/crates/apl-core/src/attributes.rs @@ -0,0 +1,215 @@ +// 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. See docs/specs/apl-design.md §4 for the module layering. + +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) + } + + 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/evaluator.rs b/crates/apl-core/src/evaluator.rs new file mode 100644 index 00000000..aa5f80d5 --- /dev/null +++ b/crates/apl-core/src/evaluator.rs @@ -0,0 +1,2563 @@ +// 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` (DSL spec §2.6); 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 anchored in: +// - DSL spec apl-dsl-spec.md §2 (operators), §3 (actions), §8.1 (require) +// - apl-design.md §7 (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 §3 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 { + Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false), + Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false), + Condition::Exists { key } => bag.contains(key), + Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag), + Condition::InSet { value_key, set_key, negate } => { + let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) { + (Some(s), Some(set)) => set.contains(s), + _ => false, // missing key or wrong type → not in set + }; + 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 (spec §2.6) + }; + + 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 { + let (a, b) = match (attr, lit) { + (AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64), + (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b), + (AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64), + (AttributeValue::Float(a), Literal::Float(b)) => (*a, *b), + // Non-numeric operands: order operators don't apply → false (spec §2.3). + _ => 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"), + } +} + +// ===================================================================== +// Async effect evaluator (policy: / post_policy: walks Vec) +// ===================================================================== + +/// Walk an Effect list against the bag, dispatching PDP calls via `pdp` +/// and plugin invocations via `plugins`. Returns the phase's overall +/// decision. +/// +/// Semantics (DSL §3, §7.5): +/// - `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 (DSL §8.9). Pre-E4 `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, + phase: crate::step::DispatchPhase, + payload: &mut crate::route::RoutePayload, +) -> StepsEvaluation { + let mut taints: Vec = Vec::new(); + let mut args_modified = false; + let mut result_modified = false; + 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, + phase, + &mut taints, + &mut args_modified, + &mut result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => {} + EffectOutcome::Halt(decision) => { + return StepsEvaluation::deny(decision, taints, args_modified, result_modified); + } + } + } + StepsEvaluation { + decision: Decision::Allow, + taints, + args_modified, + result_modified, + } +} + +/// 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, + pub args_modified: bool, + pub result_modified: bool, +} + +impl StepsEvaluation { + fn deny( + d: Decision, + taints: Vec, + args_modified: bool, + result_modified: bool, + ) -> Self { + Self { + decision: d, + taints, + args_modified, + result_modified, + } + } +} + +/// 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), +} + +/// 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, + phase: crate::step::DispatchPhase, + taints: &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::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::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, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + 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, + phase, + taints, + 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, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + 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, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + 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 { + if let EffectOutcome::Halt(reaction_decision) = + Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + return EffectOutcome::Halt(reaction_decision); + } + } + EffectOutcome::Halt(deny) + } + }, + Err(e) => EffectOutcome::Halt(Decision::Deny { + reason: Some(format!("PDP error: {}", e)), + rule_source: format!("pdp:{:?}", call.dialect), + }), + } + } + } +} + +/// 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 (E3.2) +/// +/// 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, + phase: crate::step::DispatchPhase, + taints: &'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). + 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); + branches.push(Box::pin(async move { + let mut branch_taints: 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, + phase, + &mut branch_taints, + &mut branch_args_modified, + &mut branch_result_modified, + &mut branch_payload, + )) + .await; + (outcome, branch_taints) + })); + } + + // `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: &(EffectOutcome, Vec)| { + 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)) => { + taints.extend(branch_taints); + 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() }; + let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, 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(), + }), + } +} + +// ===================================================================== +// Pipe-chain evaluator (args: / result: field pipelines) +// ===================================================================== + +/// 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 }` is currently a no-op with a TODO — the named +/// validator registry lands in a later step. +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 { + // ----- Validators ----- + 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, + }; + } + + // ----- Transforms ----- + 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; + } + + // ----- Effects ----- + Stage::Taint { label, scopes } => { + taints.push(TaintEvent { label: label.clone(), scopes: scopes.clone() }); + } + Stage::Plugin { name } => { + 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 (apl-dsl-spec §4): 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 (slice E3.2). + fn null_pipe_plugins() -> Arc { + Arc::new(NullPipelinePlugins) + } + fn null_plugins() -> Arc { + Arc::new(NullPlugins) + } + fn noop_delegations() -> Arc { + Arc::new(NoopDelegationInvoker) + } + + fn deny(reason: &str) -> Effect { + Effect::Deny { reason: Some(reason.into()), code: None } + } + + fn cond(c: Condition) -> Expression { + Expression::Condition(c) + } + + // ----- 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() { + // Spec §3: 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); + } + + // ----- Predicate semantics ----- + + #[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 (spec §2.6). + 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)); + } + + // ----- Comparison operators ----- + + #[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 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 (spec §2.3). + 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, + )); + } + + // ----- Realistic end-to-end ----- + + #[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), + } + } + + // =================================================================== + // Pipe-chain evaluator tests + // =================================================================== + + 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), + } + } + + // ----- Regex stage ----- + + #[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), + } + } + + // ----- Taint and Scan stages ----- + + #[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], + }]); + } + + // ----- Plugin stage in pipe chain ----- + + /// 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), + } + } + + // =================================================================== + // 5c additions: Exists, InSet, Always + // =================================================================== + + #[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 (spec §2.6 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), + } + } + + // =================================================================== + // 5c-v/vi: async step evaluator with mock resolvers + // =================================================================== + + 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())) + } + } + + /// 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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]); + } + + // ----- 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(), + 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")); + } + + #[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(), + 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(), + 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), + } + } + + // ----- E3: Sequential / Parallel orchestration ----- + + #[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(), + 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(), + 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(), + 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(), + 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), + } + } +} diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs new file mode 100644 index 00000000..46ee9647 --- /dev/null +++ b/crates/apl-core/src/lib.rs @@ -0,0 +1,45 @@ +// Location: ./crates/apl-core/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL core — Attribute 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`. +// +// See docs/specs/apl-design.md for the full design. + +#![doc = "APL — Attribute Policy Language. See docs/specs/apl-design.md."] + +pub mod attributes; +pub mod evaluator; +pub mod parser; +pub mod pipeline; +pub mod plugin_decl; +pub mod route; +pub mod rules; +pub mod step; + +pub use attributes::{AttributeBag, AttributeExtractor, AttributeValue}; +pub use evaluator::{ + evaluate_pipeline, evaluate_rules, evaluate_effects, 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, RouteDecision, RoutePayload}; +pub use rules::{ + CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Phase, PhaseSet, Rule, +}; +pub use step::{ + delegation_bag_keys, DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome, + DispatchPhase, NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpFactory, + PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, +}; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs new file mode 100644 index 00000000..2a41ff86 --- /dev/null +++ b/crates/apl-core/src/parser.rs @@ -0,0 +1,3929 @@ +// 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 anchored in apl-dsl-spec.md §2 (predicates) / §3 (rules) / §8 (EBNF). +// YAML shape anchored in apl-design.md §5 (`routes:` as map keyed by route_key). +// +// Step 5a scope: +// ✓ Predicate grammar: identifiers, literals, comparisons, contains, +// & | ! parens, require(...) +// ✓ Actions: deny / allow / (default deny on missing) +// ✓ YAML top-level routes: keyed map, policy: / post_policy: blocks +// ✗ 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, PdpCall, PdpDialect, Step}; + +// ===================================================================== +// Errors +// ===================================================================== + +#[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}' — defer to step 5b")] + UnsupportedStep { rule: String, kind: String }, + + #[error("predicate '{predicate}': {msg}")] + Predicate { predicate: String, msg: String }, +} + +// ===================================================================== +// Lexer +// ===================================================================== + +#[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) -> Tok { + let start = self.pos; + while let Some(b) = self.peek() { + if is_ident_cont(b) { self.pos += 1; } else { break; } + } + let s = &self.src[start..self.pos]; + 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'.' +} + +// ===================================================================== +// Predicate parser (Pratt-style; precedence () > ! > & > |) +// ===================================================================== + +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().unwrap() } 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().unwrap() } 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 DSL §8 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()` — DSL §2.2. 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 (DSL §2.4). + 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 + step-5b use. +pub fn parse_predicate(src: &str) -> Result { + PredParser::parse(src.trim()) +} + +// ===================================================================== +// Rule parser +// ===================================================================== + +/// Parse a single rule line into a `Rule`. +/// +/// Accepted forms (DSL §3.2): +/// 1. `"require(...)"` → rule-level shorthand, desugars to +/// `when: do: deny` +/// per DSL §8.1 +/// 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 (§8) 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(), + }); + } + // DSL §2 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 per DSL §8.1: +/// +/// 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().unwrap()); + } + 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(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(`. +fn detect_step_kind(s: &str) -> Option<&'static str> { + let s = s.trim_start(); + for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "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 (DSL §3 + the `code` +/// extension we added in E1): +/// +/// * `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); + // E1 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), + }) + } +} + + +// ===================================================================== +// Step parser (policy: / post_policy: entries — supports steps + rules) +// ===================================================================== + +/// Parse a single YAML entry from a `policy:` / `post_policy:` list. +/// +/// Two YAML shapes (DSL §3.2 + §7): +/// - **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` +/// - `"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) — emit as Step::Plugin. + if trimmed.starts_with("plugin(") { + let inside = extract_call_args(trimmed, "plugin") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `plugin(...)`".into(), + })?; + let name = inside.trim(); + if name.is_empty() { + return Err(ParseError::Rule { + rule: trimmed.to_string(), + msg: "plugin name must not be empty".into(), + }); + } + 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(), + })); + } + + // 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, + }) +} + +/// 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` (DSL §3.2). + // 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().unwrap(); + 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]` (DSL §3.1 + // 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); + } + + // E3: 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, + }) +} + +/// 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) +/// ``` +/// +// ===================================================================== +// Effect / when-do parsing (E1) +// ===================================================================== + +/// 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" | "cedarling" | "opa" | "authzen" | "nemo" + ) +} + +/// Parse the canonical `- when: X` `do: Y` rule form (DSL §3.2). `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]` +/// (DSL §3 example at line 386). 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) => { + // E3: `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().unwrap(); + 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), + _ => {} + } + } + } + // 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 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().unwrap()); + } + } + 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 for E1. +/// Recursively map a top-level `Step` (as produced by `parse_step`) into +/// an `Effect`. Used at compile_apl_blocks during E4 — 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::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + } +} + +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::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + Step::Rule(rule) => { + // Nested when/do inside a do: list isn't supported in E1 + // — 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 in E1 \ + (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().unwrap()) + } + Step::Pdp { .. } => Err(ParseError::Rule { + rule: source.to_string(), + msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \ + step instead)" + .into(), + }), + } +} + +/// `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 +} + +// ===================================================================== +// Pipe-chain parser (args: / result: field pipelines) +// ===================================================================== + +/// 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()) { + // ----- Bare validators / transforms / effects ----- + ("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), + // Scan placeholders parse as bare identifiers (DSL §4.5). + ("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 }), + + // ----- Parameterized ----- + ("mask", Some(a)) => { + let n: usize = a.trim().parse() + .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?; + Ok(Stage::Mask { keep_last: n }) + } + ("redact", Some(a)) => { + // 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) }) + } + ("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)) => { + 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()?, + }) + } + ("enum", Some(a)) => { + 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 }) + } + ("regex", Some(a)) => { + 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() + }; + Ok(Stage::Regex { pattern: pat }) + } + ("validate", Some(a)) => { + // Named-validator dispatch (`validate(name)`) is in the + // spec (DSL §4.2) 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 arm flips + // back to returning `Stage::Validate { name }`. + Err(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(), + ))) + } + ("plugin", Some(a)) => Ok(Stage::Plugin { name: a.trim().to_string() }), + ("taint", Some(a)) => parse_taint(a, src), + + (other, _) => Err(bad(&format!("unknown stage `{}`", other))), + } +} + +/// 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 per DSL §4.6 + } 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), + }), + } +} + +// ===================================================================== +// YAML config +// ===================================================================== + +/// Top-level config — only the bits step 5a 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 { + /// Each entry is either a string (rule / plugin / taint) or a + /// single-key map (PDP call with reactions). See `parse_step`. + #[serde(default)] + pub policy: Vec, + + #[serde(default)] + pub post_policy: Vec, + + /// `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. + #[serde(flatten)] + pub other: HashMap, +} + +/// 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 `policy:` / `post_policy:` / +/// `args:` / `result:`) are **omitted from `routes`**, per apl-design §5 +/// "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 }) +} + +fn compile_route(route_key: &str, raw: RouteYaml) -> Result, ParseError> { + let has_apl = !raw.policy.is_empty() + || !raw.post_policy.is_empty() + || !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 (policy/post_policy/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"`). +fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result { + let mut route = CompiledRoute::new(source); + for (i, entry) in raw.policy.iter().enumerate() { + let step = parse_step(entry, &format!("{}.policy[{}]", source, i))?; + route.policy.push(step_to_top_level_effect(step)?); + } + for (i, entry) in raw.post_policy.iter().enumerate() { + let step = parse_step(entry, &format!("{}.post_policy[{}]", 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" +/// policy: +/// - "require(authenticated)" +/// result: +/// ssn: "redact(!perm.view_ssn)" +/// post_policy: +/// - "taint(forward)" +/// ``` +/// +/// 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) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use crate::attributes::AttributeBag; + use crate::evaluator::Decision; + + // ----- Lexer ----- + + #[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 `==`")); + } + + // ----- 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` from DSL §2.5. + 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 per DSL §8, 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 } (DSL §8.1) + 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 (DSL §8.1) + // 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 (DSL §8.1) + // 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")); + } + + // ----- Rule parser ----- + + #[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() { + // DSL §2: 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 (DSL §3.1). + 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_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')` per DSL §3 and + // the E1 `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')` — E1 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")); + } + + // ----- E1: when/do canonical form ----- + + 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 E1 `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")); + } + + // ----- E1: shorthand multi-effect map (predicate: [list]) ----- + + #[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")); + } + + // ----- E2: content effects in do: (field pipe chains) ----- + + #[test] + fn when_do_with_field_op_result_redact() { + // The headline E2 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_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); + } + + // ----- E3: sequential: / parallel: parsing ----- + + #[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")); + } + + #[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), + } + } + + // ----- Colon-splitting edge cases ----- + + #[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"); + } + } + + // ----- YAML compilation ----- + + #[test] + fn compile_simple_route() { + let yaml = r#" +routes: + get_compensation: + policy: + - "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 compile_omits_routes_without_apl_blocks() { + // A route with no APL blocks (no policy / post_policy / args / + // result) is a "legacy" route per apl-design §5 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: + policy: + - "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: + policy: + - "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: + policy: + - "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: + policy: + - "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_taint_step_string_form() { + let yaml = r#" +routes: + audit_marked: + policy: + - "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: + policy: + - 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_cedarling_map_form() { + // `cedarling:` is its own dialect — same map shape as `cedar:` + // but routes to the Cedarling-backed resolver in the + // PdpRouter, letting cedar-direct and cedarling coexist. + let yaml = r#" +routes: + authz_check: + policy: + - cedarling: + action: read + resource: employee + 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, .. } => { + assert_eq!(call.dialect, PdpDialect::Cedarling); + let args_map = call.args.as_mapping().expect("cedarling 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); + } + 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: + policy: + - '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: + policy: + - 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), + } + } + + // ----- End-to-end with evaluator ----- + + #[tokio::test] + async fn end_to_end_hr_compensation() { + let yaml = r#" +routes: + get_compensation: + policy: + - "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); + + // 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, 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, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy[2]"), "expected policy[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, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy[1]"), "expected policy[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"); + } + } + + // ----- Pipeline parsing ----- + + #[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 per DSL §4.1. + let err = parse_pipeline("omit(!perm.x)").unwrap_err(); + assert!(format!("{}", err).contains("omit takes no arguments")); + } + + // ----- YAML compilation with pipelines ----- + + #[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 `policy:` but with `args:` validators is still + // an APL route (declared_phases is 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")); + } + + // ----- plugins: block + route-level overrides ----- + + #[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: + policy: + - "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: + policy: + - "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()]); + } + + // ----- compile_policy_block_value (single-block compiler for visitors) ----- + + #[test] + fn compile_policy_block_value_parses_apl_body() { + let yaml = r#" +policy: + - "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#" +policy: + - "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.policy[0]"); + } + other => panic!("expected When, got {:?}", other), + } + } + + // ----- delegate: step parsing ----- + + #[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}"); + } + + // ----- delegate(...) function-call string form ----- + + #[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: + policy: + - "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: + policy: + - "require(role.hr)" + - delegate: + plugin: workday-oauth + config: + target: workday-api + permissions: [read_compensation] + - "require(authenticated)" + post_policy: + - 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.policy[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_policy[0]"); + } + + // ----- validate(name) compile-time rejection (DSL spec §4.2) ----- + + #[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 new file mode 100644 index 00000000..0eae7b1c --- /dev/null +++ b/crates/apl-core/src/pipeline.rs @@ -0,0 +1,134 @@ +// 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. +// +// Grounded in apl-dsl-spec.md §4. +// +// Stages whose evaluator behavior is deferred to step 5c (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 { + // ----- Validators (halt with deny on failure) ----- + 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 per DSL §4.3. + Range { min: Option, max: Option }, + /// `enum(a, b, c)` — value must equal one of the listed strings. + Enum { values: Vec }, + + // ----- Transforms (produce a new value) ----- + /// `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 + /// per DSL §4.1 — use a policy rule for conditional omit. + Omit, + /// `hash` — replace value with a hash digest. + Hash, + + // ----- Effects (deferred to step 5c — IR captured, eval stubbed) ----- + 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 new file mode 100644 index 00000000..1b64fc88 --- /dev/null +++ b/crates/apl-core/src/plugin_decl.rs @@ -0,0 +1,290 @@ +// 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. +// +// Spec: `contextforge-plugins-framework-apl/docs/specs/unified-config-proposal.md`, +// §"Plugin Declaration" (lines 173+) +// §"Route-Level Plugin Config Overrides" (lines 360+) +// +// Layering, per spec: +// - 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). + /// + /// Per spec §"Hook dispatch": 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. + /// + /// Spec §"Route-Level Plugin Config Overrides": + /// - 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 new file mode 100644 index 00000000..30dff509 --- /dev/null +++ b/crates/apl-core/src/route.rs @@ -0,0 +1,739 @@ +// Location: ./crates/apl-core/src/route.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Phase orchestration: runs `args → policy → result → post_policy` 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 (anchored in apl-dsl-spec.md §3): +// - 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_pipeline, evaluate_effects, Decision, FieldOutcome}; +use crate::pipeline::TaintEvent; +use crate::rules::CompiledRoute; +use crate::step::{DelegationInvoker, DispatchPhase, 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. +#[derive(Debug, Clone)] +pub struct RouteDecision { + pub decision: Decision, + /// Taints accumulated from any phase. Empty unless a pipeline emitted them. + pub taints: 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, +} + +/// 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, +) -> RouteDecision { + let mut taints: Vec = Vec::new(); + let mut args_modified = false; + + // ----- args ----- + 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, + args_modified, + result_modified: false, + }; + } + } + } + + // ----- policy ----- + let policy_eval = evaluate_effects( + &route.policy, + bag, + pdp, + plugins, + delegations, + 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, + args_modified, + result_modified: false, + } +} + +/// 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, +) -> RouteDecision { + let mut taints: Vec = Vec::new(); + let mut result_modified = false; + + // ----- result (only when a response payload is present) ----- + 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, + args_modified: false, + result_modified, + }; + } + } + } + } + + // ----- post_policy ----- + let post_eval = evaluate_effects( + &route.post_policy, + bag, + pdp, + plugins, + delegations, + 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, + args_modified: false, + result_modified, + } +} + +/// 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, +) -> RouteDecision { + let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations).await; + if matches!(pre.decision, Decision::Deny { .. }) { + return pre; + } + let post = evaluate_post(route, bag, payload, pdp, plugins, delegations).await; + let mut taints = pre.taints; + taints.extend(post.taints); + RouteDecision { + decision: post.decision, + taints, + args_modified: pre.args_modified, + result_modified: post.result_modified, + } +} + +// ===================================================================== +// Dotted-path JSON helpers +// ===================================================================== + +/// 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. +pub(crate) 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, PdpCall, PdpDecision, PdpDialect, PdpError, PluginError, + PluginInvocation, PluginOutcome, + }; + use async_trait::async_trait; + use serde_json::json; + + // ----- Fixtures ----- + + 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())) + } + } + + // `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 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, + ) + } + + // ----- Tests ----- + + #[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()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + assert!(!r.result_modified); + assert!(r.taints.is_empty()); + } + + #[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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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]")); + } + + // ----- Helper unit tests ----- + + #[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 } })); + // Removing a missing leaf returns false. + assert!(!remove_dotted(&mut v, "a.b")); + } + + // ----- evaluate_pre / evaluate_post (phase split) ----- + + #[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()).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()).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()).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()).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 new file mode 100644 index 00000000..7fd707aa --- /dev/null +++ b/crates/apl-core/src/rules.rs @@ -0,0 +1,840 @@ +// 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. See docs/specs/apl-design.md §7. + +use serde::{Deserialize, Serialize}; + +/// Comparison operators in DSL predicates. +/// +/// `In` / `NotIn` are intentionally absent: the DSL spec §2.4 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 DSL spec §8.1 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). Per DSL §2.2. + 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`. Per DSL §2.4. 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 +/// (DSL §3.1): `- 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 DSL spec §3 effect classes: +/// +/// * Control — `Allow`, `Deny` +/// * Label — `Taint` +/// * Host — `Plugin`, `Delegate` +/// +/// Content effects (`redact`, `mask`, `omit`, `hash`) and orchestration +/// (`Sequential`, `Parallel`) land in later slices (E2 / E3). +/// +/// PDP calls (`cedar:(…)`, `opa(…)`, …) remain top-level [`Step`] +/// variants for now; folding them into `Effect` is an E4 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), + Taint { + label: String, + scopes: Vec, + }, + /// Content effect (DSL §3) — 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 (DSL §7.5). 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 { + Effect::FieldOp { .. } | Effect::Delegate(_) => 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 { .. } => 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. +/// +/// See docs/specs/apl-design.md §3 — 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, + } + } +} + +/// 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 §1.2 / §4 / §7. +#[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, +} + +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); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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]", + ] + ); + } + + // ----- E3: parallel-purity validation ----- + + #[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 new file mode 100644 index 00000000..dca1921e --- /dev/null +++ b/crates/apl-core/src/step.rs @@ -0,0 +1,506 @@ +// 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(...)` +// 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. +// +// Grounded in apl-dsl-spec.md §3 (effects) / §7 (PDP integration) and +// apl-design.md §8.1 (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 as of +/// E4 — external code dispatches on `Effect` everywhere. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Step { + /// Predicate-and-action rule (the existing 5a/5b/5c case). + Rule(Rule), + + /// External PDP call. `on_deny` / `on_allow` are reaction Step lists + /// that fire based on the PDP's decision (DSL §7.5). + 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. See + /// `docs/apl-identity-delegation-design.md`. + 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 }, +} + +/// One delegation invocation inside `policy:` or `post_policy:`. +/// +/// 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 `policy:` + `post_policy:` or reach for a +/// future per-step `as:` alias (not in v0; see the design doc's +/// "Open design questions" section). +#[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, +} + +/// 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 (`apl-pdp-cedar-direct`). + Cedar, + /// Cedarling-mediated Cedar evaluation — same language but + /// adds signed policy stores, multi-issuer JWT validation, and + /// (with Lock Server) centralized policy management. Distinct + /// from `Cedar` so both can coexist in a single `PdpRouter`; + /// route YAML can target either with `cedar:(...)` or + /// `cedarling:(...)` keys. + Cedarling, + Opa, + AuthZen, + NeMo, + #[serde(untagged)] + Custom(String), +} + +impl PdpDialect { + /// Parse a YAML key prefix like `cedar`, `cedarling`, `opa`, + /// `authzen`, `nemo` into the matching `PdpDialect`. Unknown + /// dialects become `Custom`. + pub fn from_key(key: &str) -> Self { + match key { + "cedar" => Self::Cedar, + "cedarling" => Self::Cedarling, + "opa" => Self::Opa, + "authzen" => Self::AuthZen, + "nemo" => Self::NeMo, + other => Self::Custom(other.to_string()), + } + } +} + +// ===================================================================== +// Resolver traits +// ===================================================================== + +/// External policy-decision dispatch. Implemented by Cedar/Cedarling, 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, cedarling, 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"`, `"cedarling"`, `"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` +/// * `policy:` steps → `Pre` +/// * `result:` field stages → `Post` +/// * `post_policy:` 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 `policy:` or `post_policy:` 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 { + 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), +} + +/// `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())) + } +} + +// ===================================================================== +// Resolver results +// ===================================================================== + +/// 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. + 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 } + } +} + +// ===================================================================== +// Errors +// ===================================================================== + +#[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), +} + +// ===================================================================== +// Convenience +// ===================================================================== + +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. See +/// `docs/specs/delegation-hooks-rust-spec.md` §6.3 for that mapping. +/// +/// 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"; +} diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs new file mode 100644 index 00000000..6227aaab --- /dev/null +++ b/crates/apl-core/tests/yaml_end_to_end.rs @@ -0,0 +1,266 @@ +// 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 follows Example 1 from unified-config-proposal.md, adapted to +// the map-keyed `routes:` shape that the parser actually accepts (the spec's +// list-with-matchers form is a deferred shape). + +use std::sync::Arc; + +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, FieldOutcome, + NoopDelegationInvoker, 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) +} + +// ----- Fixtures: a baseline route used by every scenario below. ----- + +const HR_ROUTE_YAML: &str = r#" +routes: + get_employee: + args: + employee_id: "str" + policy: + - "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()).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()).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()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy"), "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()).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()).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()).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 new file mode 100644 index 00000000..b0b50f4c --- /dev/null +++ b/crates/apl-cpex/Cargo.toml @@ -0,0 +1,43 @@ +# 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 + +[dependencies] +apl-core = { path = "../apl-core" } +apl-cmf = { path = "../apl-cmf" } +cpex-core = { path = "../cpex-core" } +async-trait = { workspace = true } +chrono = { 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.10" + +[dev-dependencies] +serde = { workspace = true } diff --git a/crates/apl-cpex/src/cmf_invoker.rs b/crates/apl-cpex/src/cmf_invoker.rs new file mode 100644 index 00000000..6abb3cac --- /dev/null +++ b/crates/apl-cpex/src/cmf_invoker.rs @@ -0,0 +1,410 @@ +// 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. +// +// # 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::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; + +/// 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>, + /// 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, + ) -> Self { + // 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 + // OR no stored labels (avoid the COW clone for nothing). + 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); + + Self { + manager, + extensions: Arc::new(Mutex::new(extensions)), + payload: Arc::new(Mutex::new(payload)), + 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() + } + + /// 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: TS2 — 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 when there + /// was no session ID. Host calls this exactly once after route + /// evaluation completes. + pub async fn persist_session(&self) { + let Some(sid) = &self.session_id else { return }; + let current = self.extensions.lock().await; + let Some(security) = current.security.as_ref() else { return }; + 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; + } + } +} + +#[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); + + 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. + let modified_value = 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(); + match invocation { + PluginInvocation::Field { .. } => { + Some(serde_json::Value::String( + modified.message.get_text_content(), + )) + } + PluginInvocation::Step { .. } => None, + } + } + None => { + 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, + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// 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 new file mode 100644 index 00000000..c4447d47 --- /dev/null +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -0,0 +1,269 @@ +// 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 `policy:` / `post_policy:` 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, 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 { + // 1. Resolve the plugin's token.delegate entry from the plan. + // Routes that don't reference this plugin in `policy:` / + // `post_policy:` at compile time won't have it in the plan + // — 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(); + + // 2. Snapshot extensions to construct the payload + pass into + // invoke_entries. We keep the canonical copy under the + // Mutex; this snapshot is the per-call working copy. + let current_extensions = self.extensions.lock().await.clone(); + + // 3. Pull the inbound bearer token from raw_credentials. v0 + // looks for the User-role token; future iterations can + // surface multi-token selection (Client / Workload) via + // step config. + let bearer_token = current_extensions + .raw_credentials + .as_ref() + .and_then(|rc| rc.inbound_tokens.get(&TokenRole::User)) + .map(|tok| (*tok.token).clone()) + .unwrap_or_default(); + + // 4. Read step args. Step `config_override` is a yaml map per + // the IR — we 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 at registration time (already applied when the + // plan was built — plugins consume them from their + // `cfg.config`). For Slice B we keep this mapping minimal: + // `target` is required (delegation needs to know who the + // downstream call is for); `audience`, `permissions`, + // `mode`, `auth_enforced_by` are recognized; everything + // else stays opaque. + let cfg = step + .config_override + .as_ref() + .and_then(|v| v.as_mapping()); + + 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(); + + let mut payload = DelegationPayload::new(bearer_token, target_name); + + 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)); + } + + // 5. 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; + + // 6. 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)); + } + + // 7. 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; + } + + // 8. 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, + }) + } +} + +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, + } +} diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs new file mode 100644 index 00000000..3fbafdb9 --- /dev/null +++ b/crates/apl-cpex/src/dispatch_plan.rs @@ -0,0 +1,461 @@ +// 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`. Per `feedback_override_isolation.md`: 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::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:` / `policy:`, `Post` for + /// `result:` / `post_policy:`, `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, +} + +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 (Slice B + // doesn't expose per-step caps or on_error overrides 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", + ); + } + } + + 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. Pre-E4 these were flat — Step::Plugin lived directly under +/// policy: — so a simple iter() was enough; after E4 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 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()); + } + } + } + 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/lib.rs b/crates/apl-cpex/src/lib.rs new file mode 100644 index 00000000..b5f6aaef --- /dev/null +++ b/crates/apl-cpex/src/lib.rs @@ -0,0 +1,52 @@ +// 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 spec §4.2 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 cmf_invoker; +pub mod delegation_invoker; +pub mod dispatch_plan; +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 cmf_invoker::CmfPluginInvoker; +pub use delegation_invoker::DelegationPluginInvoker; +pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; +pub use pdp_router::PdpRouter; +pub use register::{register_apl, AplOptions}; +pub use route_handler::{AplRouteHandler, Phase}; +pub use session_store::{MemorySessionStore, SessionStore}; +pub use visitor::AplConfigVisitor; diff --git a/crates/apl-cpex/src/parallel_safety.rs b/crates/apl-cpex/src/parallel_safety.rs new file mode 100644 index 00000000..2550927b --- /dev/null +++ b/crates/apl-cpex/src/parallel_safety.rs @@ -0,0 +1,343 @@ +// 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 [ + ("policy", route.policy.as_slice()), + ("post_policy", 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 + ) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[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() }]) + } + + // --- Allowed modes --- + + #[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()); + } + + // --- Rejected modes --- + + #[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")); + } + + // --- Scoping: only mismatches INSIDE a parallel block are caught --- + + #[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")); + } + + // --- Diagnostics: every violation, both phases --- + + #[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_policy")); + } +} diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs new file mode 100644 index 00000000..bbfa12fa --- /dev/null +++ b/crates/apl-cpex/src/pdp_router.rs @@ -0,0 +1,207 @@ +// 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 Cedar **and** OPA **and** +// NeMo at the same time without having to pick one at construction. +// +// 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 new file mode 100644 index 00000000..ab6becf2 --- /dev/null +++ b/crates/apl-cpex/src/register.rs @@ -0,0 +1,185 @@ +// 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; +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`, `cedarling`, `opa`. An empty list disables + /// config-driven PDP wiring; hosts can still supply resolvers via + /// `pdps`. + pub pdp_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(), + 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 apl_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, + 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); + } + + 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 new file mode 100644 index 00000000..d1d1b838 --- /dev/null +++ b/crates/apl-cpex/src/route_handler.rs @@ -0,0 +1,569 @@ +// 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::{extract_args, extract_result, BagBuilder}; +use apl_core::evaluator::Decision; +use apl_core::plugin_decl::PluginRegistry; +use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload}; +use apl_core::rules::CompiledRoute; +use apl_core::step::PdpResolver; + +use crate::cmf_invoker::CmfPluginInvoker; +use crate::delegation_invoker::DelegationPluginInvoker; +use crate::dispatch_plan::DispatchCache; +use crate::pdp_router::PdpRouter; +use crate::session_store::SessionStore; + +/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post +/// covers `result` + `post_policy`. 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, +} + +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()), + } + } + + /// 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. + let invoker = Arc::new( + CmfPluginInvoker::for_request( + Arc::clone(&manager), + extensions.clone(), + msg_payload.clone(), + plan, + Arc::clone(&self.session_store), + ) + .await, + ); + + // 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) + .build(); + + // 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); + } + } + + // Slice B: 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. + let invoker_dyn: Arc = invoker.clone(); + let delegations_dyn: Arc = delegations.clone(); + + let decision = match self.phase { + Phase::Pre => { + evaluate_pre( + &self.route, + &mut bag, + &mut route_payload, + &self.pdp, + &invoker_dyn, + &delegations_dyn, + ) + .await + } + Phase::Post => { + evaluate_post( + &self.route, + &mut bag, + &mut route_payload, + &self.pdp, + &invoker_dyn, + &delegations_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 + // (see TS2). No-op when no taints emitted. + invoker.apply_session_taints(&decision.taints).await; + + // Commit any session-scoped labels accumulated during this + // request. No-op when there was no session id. + 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; + + // Detect whether the args pipeline mutated the payload by + // re-extracting from the pre-eval message (msg_payload is + // still borrowed) and comparing against the post-eval + // route_payload.args. Re-extraction allocates but mirrors the + // surrounding pattern and avoids holding a pre-eval clone. + let pre_args = extract_args_from_message(&msg_payload.message); + // For Post phase, also detect result mutations from `result:` + // pipelines. Pre routes don't carry a result so this is None. + let pre_result = match self.phase { + Phase::Pre => None, + Phase::Post => Some(extract_result_from_message(&msg_payload.message)), + }; + let modified_payload: Option> = + if route_payload.args != pre_args { + // 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. + let mut updated = final_payload.clone(); + write_args_back_to_message(&mut updated.message, &route_payload.args); + Some(Box::new(updated) as Box) + } else if matches!(self.phase, Phase::Post) + && pre_result + .as_ref() + .zip(route_payload.result.as_ref()) + .map(|(prev, current)| prev != current) + .unwrap_or(false) + { + // 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. + let mut updated = final_payload.clone(); + if let Some(result_value) = route_payload.result.as_ref() { + write_result_back_to_message(&mut updated.message, result_value); + } + Some(Box::new(updated) as Box) + } else if msg_payload.message.get_text_content() + != final_payload.message.get_text_content() + { + // A `policy:` plugin mutated the message directly via + // `modify_payload` (not through a field pipeline). Pass + // the invoker's view through unchanged. + Some(Box::new(final_payload) as Box) + } else { + None + }; + + let modified_extensions = if extensions_changed(extensions, &final_extensions) { + Some(final_extensions.cow_copy()) + } else { + None + }; + + let (continue_processing, 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()); + (false, Some(PluginViolation::new(code, reason))) + } + }; + + 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" + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// 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. +fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) { + for part in msg.content.iter_mut() { + if let cpex_core::cmf::ContentPart::Text { text } = part { + *text = new_text.to_string(); + return; + } + } + msg.content.push(cpex_core::cmf::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. +fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value { + use cpex_core::cmf::ContentPart; + 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. +fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) { + use cpex_core::cmf::ContentPart; + 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. +fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value { + use cpex_core::cmf::ContentPart; + 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. +fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) { + use cpex_core::cmf::ContentPart; + 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); + } +} + +/// 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 +} + diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs new file mode 100644 index 00000000..77615c96 --- /dev/null +++ b/crates/apl-cpex/src/session_resolver.rs @@ -0,0 +1,432 @@ +// 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. Highest priority because it +// represents authority, not derivation — overriding this with a +// derived value would discard that upstream decision. Plugins +// that need bespoke session resolution (e.g., reading from a +// separate session-management service) write here and let the +// resolver pick it up. +// +// 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"]`. +// +// 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16]. +// No special infrastructure needed; the triple is already populated +// by `apl-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 (apl-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", + } + } +} + +/// 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)> { + // Tier 0: pre-resolved by an upstream plugin. Authoritative — + // wins over every derived tier so plugin-supplied custom session + // resolution isn't silently overridden by a derived hash. + if let Some(agent) = ext.agent.as_deref() { + if let Some(sid) = agent.session_id.as_deref() { + if !sid.is_empty() { + return Some((sid.to_string(), SessionSource::Agent)); + } + } + } + + // Tier 1: explicit JWT claim. + 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() { + return Some((sid.clone(), 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); + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + // 16 hex chars = 64 bits — plenty for the workload sizes + // CPEX targets, matches the Python implementation's + // `hexdigest()[:16]`. + let digest = hasher.finalize(); + let hex: String = digest + .iter() + .take(8) + .map(|b| format!("{:02x}", b)) + .collect(); + return Some((hex, SessionSource::Identity)); + } + } + + // Tier 3: no session. + None +} + +// ===================================================================== +// Tests — one scenario per tier, plus tier-priority assertions. +// ===================================================================== + +#[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() + } + } + + // --- Tier 0: agent (pre-resolved) --- + + #[test] + fn tier0_agent_session_id_hits_first() { + let mut agent = AgentExtension::default(); + agent.session_id = Some("sess-upstream".into()); + let ext = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(sid, "sess-upstream"); + assert_eq!(src, SessionSource::Agent); + } + + #[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)), + ..Default::default() + }; + assert!(resolve_session(&ext).is_none()); + } + + #[test] + fn tier0_wins_over_token_claim() { + // Pre-resolved value beats a JWT claim — upstream authority. + 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!(sid, "from-agent"); + assert_eq!(src, SessionSource::Agent); + } + + // --- Tier 1: token_claim --- + + #[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!(sid, "sess-from-token-789"); + assert_eq!(src, SessionSource::TokenClaim); + } + + #[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); + } + + // --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent --- + // + // 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`. + + // --- Tier 2: identity --- + + #[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); + } + + // --- Tier 3: none --- + + #[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()); + } + + // --- Spoofing guard (regression test for P0-2) --- + + #[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 new file mode 100644 index 00000000..54f70378 --- /dev/null +++ b/crates/apl-cpex/src/session_store.rs @@ -0,0 +1,157 @@ +// 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::RwLock; + +use async_trait::async_trait; + +/// 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. +/// - Empty `load_labels` for an unknown `session_id` is the right +/// response — non-session traffic shouldn't fail, it just sees no +/// accumulated state. +#[async_trait] +pub trait SessionStore: Send + Sync { + /// Load the union of labels accumulated for the session. Empty for + /// new or unknown sessions. + async fn load_labels(&self, session_id: &str) -> Vec; + + /// 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. + async fn append_labels(&self, session_id: &str, labels: &[String]); +} + +/// 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) -> Vec { + let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); + r.get(session_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + } + + async fn append_labels(&self, session_id: &str, labels: &[String]) { + if labels.is_empty() { + return; + } + 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()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn load_for_unknown_session_is_empty() { + let store = MemorySessionStore::new(); + assert!(store.load_labels("nonexistent").await.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; + let mut labels = store.load_labels("sess-1").await; + 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; + store + .append_labels("sess-1", &["PII".to_string(), "PII".to_string()]) + .await; + let labels = store.load_labels("sess-1").await; + 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; + store.append_labels("b", &["Y".to_string()]).await; + assert_eq!(store.load_labels("a").await, vec!["X".to_string()]); + assert_eq!(store.load_labels("b").await, 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; + assert_eq!(c2.load_labels("sess").await, vec!["Z".to_string()]); + } +} diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs new file mode 100644 index 00000000..cf9eea71 --- /dev/null +++ b/crates/apl-cpex/src/visitor.rs @@ -0,0 +1,680 @@ +// 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_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 cpex_core::config::RouteEntry; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::PluginConfig; +use cpex_core::visitor::{ConfigVisitor, VisitorError}; + +use apl_core::parser::compile_policy_block_value; +use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry}; +use apl_core::rules::CompiledRoute; +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; + +/// 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"`, +/// `"cedarling"`, …). The visitor knows nothing about specific PDP +/// backends; everything dispatches through `PdpFactory`. +pub struct AplConfigVisitor { + state: RwLock, + dispatch_cache: Arc, + session_store: Arc, + 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>, +} + +impl AplConfigVisitor { + pub fn new( + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + ) -> Self { + Self { + state: RwLock::new(VisitorState::default()), + dispatch_cache, + session_store, + manager, + base_capabilities: default_base_capabilities(), + pdp_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); + } + + /// 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); + } + + /// 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(()) + } +} + +/// 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> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + + // Process `apl.pdp[]` before stacking the policy/post_policy + // 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)?; + } + } + + // The `pdp:` sub-key isn't an APL DSL field; strip it before + // handing the block to `compile_policy_block_value` so the + // compiler doesn't see an unknown key. `compile_policy_block_value` + // accepts maps with `policy:` / `post_policy:` / `args:` / + // `result:` / `plugins:` (and inert fields it ignores), so a + // shallow strip on a clone is enough. + let policy_only = strip_pdp_key(apl_block); + let compiled = compile_policy_block_value("global.apl", &policy_only) + .map_err(|e| Box::new(e) as VisitorError)?; + 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 Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + let source = format!("global.defaults.{}.apl", entity_type); + 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 Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + let source = format!("global.policies.{}.apl", tag); + 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. + 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(()); + } + }; + 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 plugin registry + PDP router once outside the + // per-entity loop. `visit_plugins` populated the registry + // before any `visit_route` call; the router has been populated + // by code-supplied `register_pdp` calls + `visit_global` + // factory dispatch. Routes share both, so cloning each into an + // `Arc` once and handing clones to each handler is cheaper than + // re-reading the RwLock per entity. Cloning `PdpRouter` is + // refcount bumps on each inner resolver — cheap. + 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, + ) + }; + + for entity_name in &entity_names { + // 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); + } + + // 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; + } + + // E3.1 — 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; + } + }; + + // 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, + &self.session_store, + &self.manager, + Some(Arc::clone(&pdp_router_arc)), + &self.base_capabilities, + ); + install_handler( + mgr, + entity_type, + entity_name, + scope.clone(), + hook_post, + Phase::Post, + route_arc, + &plugin_registry, + &self.dispatch_cache, + &self.session_store, + &self.manager, + Some(Arc::clone(&pdp_router_arc)), + &self.base_capabilities, + ); + } + + Ok(()) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +#[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, +) { + // 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)); + + 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(), + ); + 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(), + } +} + +/// Strip the `pdp` sub-key from an `apl:` mapping so the remainder can +/// be handed to `compile_policy_block_value` (which doesn't model PDP +/// declarations — those are CPEX wiring concerns). Returns a clone of +/// the mapping with `pdp` removed; the original is left intact. +fn strip_pdp_key(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(); + cloned.remove(&serde_yaml::Value::String("pdp".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() +} + +/// Pull the `apl:` sub-block out of a section's raw YAML. Returns `None` +/// when absent or null — callers treat that as "no contribution from +/// this section" and move on. +fn apl_subblock(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> { + let block = yaml.get("apl")?; + if block.is_null() { + None + } else { + Some(block) + } +} diff --git a/crates/apl-cpex/tests/capability_gating.rs b/crates/apl-cpex/tests/capability_gating.rs new file mode 100644 index 00000000..d03656e2 --- /dev/null +++ b/crates/apl-cpex/tests/capability_gating.rs @@ -0,0 +1,446 @@ +// 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: + policy: + - "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(), + 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: + policy: + - "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(), + 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: + policy: + - "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: + policy: + - "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: + policy: + - "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(), + 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 new file mode 100644 index 00000000..c95788b3 --- /dev/null +++ b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs @@ -0,0 +1,699 @@ +// 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::{CmfHook, ContentPart, Message, MessagePayload}; +use cpex_core::cmf::enums::Role; +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() }) +} + +// --------------------------------------------------------------------- +// 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)), + )], + }) + } +} + +// --------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------- + +fn payload_with_text(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +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; + + 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; + + 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; + + 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; + + 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]" + ); +} + +// --------------------------------------------------------------------- +// 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 per `feedback_override_isolation.md`. +// --------------------------------------------------------------------- + +/// 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() }) +} + +#[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; + + 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, + ); +} + +// --------------------------------------------------------------------- +// Slice 101 — 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. Before Slice 101 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; + + // 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 new file mode 100644 index 00000000..3bfb705d --- /dev/null +++ b/crates/apl-cpex/tests/config_override.rs @@ -0,0 +1,519 @@ +// 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(), + 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: + policy: + - "plugin(gate)" + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "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"] + policy: + - "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 + policy: + - "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"] + policy: + - "plugin(gate)" + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "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_cpex::{DispatchCache, RouteDispatchPlan}; + use apl_core::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; + use apl_core::rules::{CompiledRoute, Effect}; + 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 new file mode 100644 index 00000000..ffd973ac --- /dev/null +++ b/crates/apl-cpex/tests/delegate_step_e2e.rs @@ -0,0 +1,913 @@ +// 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 (Slice B). +// +// 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 (spec §3). 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: + policy: + - "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); + 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), + ) + .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: + policy: + - "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); + 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), + ) + .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: + policy: + - "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); + 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), + ) + .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: + policy: + - "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); + 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), + ) + .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: + policy: + - "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); + 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), + ) + .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: + policy: + - "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); + 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), + ) + .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/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs new file mode 100644 index 00000000..184c8149 --- /dev/null +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -0,0 +1,551 @@ +// 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, PdpCall, + PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, +}; + +use apl_cpex::{CmfPluginInvoker, DispatchCache, MemorySessionStore, SessionStore}; + +// --------------------------------------------------------------------- +// 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: + policy: + - "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); + + 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)).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: + policy: + - "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); + + 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)).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: + policy: + - "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) — lets the test + // specify an exact value without faking the identity hash. + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-taint-test".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let session_store = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + extensions, + cmf_payload(), + plan, + session_store.clone(), + ) + .await); + + 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)).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; + let stored = session_store.load_labels("sess-taint-test").await; + 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). + let session_store = Arc::new(MemorySessionStore::new()); + session_store + .append_labels("sess-existing", &["PRIOR".to_string()]) + .await; + + 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: + policy: + - "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 mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-existing".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); + + // 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)).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; + let mut stored = session_store.load_labels("sess-existing").await; + stored.sort(); + assert_eq!(stored, vec!["PII".to_string(), "PRIOR".to_string()]); +} + +/// Slice TS1 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: + policy: + - "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 mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-apl-taint".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let session_store = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); + + 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), + ) + .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; + let stored = session_store.load_labels("sess-apl-taint").await; + assert_eq!(stored, vec!["audit".to_string()]); +} diff --git a/crates/apl-cpex/tests/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs new file mode 100644 index 00000000..c3ca4cde --- /dev/null +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -0,0 +1,705 @@ +// 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(), + 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: + policy: + - "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: + policy: + - "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: + policy: + - "plugin(allow-gate)" +routes: + - tool: get_weather + apl: + policy: + - "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: + policy: + - "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: + policy: + - "plugin(deny-gate)" + - tool: get_weather + apl: + policy: + - "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()); +} + +/// 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. +// --------------------------------------------------------------------- +// Slice 102 — multi-entity-type route support (llm / prompt / resource) +// --------------------------------------------------------------------- +// +// Pre-Slice-102, 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: + policy: + - "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`. +/// Pre-Slice-102, 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_policy: + - "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: + policy: + - "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: + policy: + - "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`. Pre-Slice-102, 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: + policy: + - "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: + policy: + - "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 + ); +} diff --git a/crates/apl-delegator-biscuit/Cargo.toml b/crates/apl-delegator-biscuit/Cargo.toml new file mode 100644 index 00000000..f040f614 --- /dev/null +++ b/crates/apl-delegator-biscuit/Cargo.toml @@ -0,0 +1,67 @@ +# Location: ./crates/apl-delegator-biscuit/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-delegator-biscuit — `TokenDelegateHandler` that performs +# biscuit-auth capability-token attenuation. +# +# # Why this exists +# +# Slice 3's `TokenDelegateHook` defines the surface; this crate is +# the *decentralized* backend — cryptographic scope attenuation +# without an IdP roundtrip. Reads the inbound biscuit, appends a +# delegation block narrowing the capabilities (resource / operation +# / time-bound checks), produces a new biscuit base64-encoded as +# the outbound credential. +# +# # AIP alignment +# +# The IETF draft `draft-prakash-aip-00` (Agent Identity Protocol) +# defines a "Chained Mode" using biscuit tokens with +# authority/delegation/completion blocks. This crate produces the +# delegation-block half of that flow; the authority block is the +# inbound biscuit; completion blocks land in a future post-result +# audit hook. +# +# # When to reach for this vs `apl-delegator-oauth` +# +# - **`apl-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. +# - **`apl-delegator-oauth`** (slice 6) — RFC 8693 against an +# OAuth IdP. Use when centralized audit/revocation matters more +# than roundtrip cost. + +[package] +name = "apl-delegator-biscuit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# biscuit-auth v6 — current major. Maintained by Clever Cloud + +# community. Ed25519 + Datalog. No default-features off needed; the +# default feature set is reasonable (no Tonic/network deps). +biscuit-auth = "6" + +# `hex` for parsing raw 32-byte Ed25519 public keys from config. +# biscuit-auth doesn't re-export hex parsing. +hex = "0.4" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-delegator-biscuit/src/config.rs b/crates/apl-delegator-biscuit/src/config.rs new file mode 100644 index 00000000..16c3ac0d --- /dev/null +++ b/crates/apl-delegator-biscuit/src/config.rs @@ -0,0 +1,161 @@ +// Location: ./crates/apl-delegator-biscuit/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `BiscuitDelegator`. + +use std::path::PathBuf; + +use biscuit_auth::PublicKey; +use serde::{Deserialize, Serialize}; + +/// Plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiscuitDelegatorConfig { + /// The root public key the inbound biscuit was signed against. + /// Verification fails if the inbound's authority-block signature + /// doesn't validate under this key. + pub root_public_key: PublicKeySource, + + /// Header name the forwarding plugin should attach the minted + /// token under. Most downstream services expect + /// `Authorization` or a custom `X-AIP-Token`-style header. + #[serde(default = "default_outbound_header")] + pub default_outbound_header: String, + + /// Default TTL for the appended delegation block, in seconds. + /// Per-call overrides come from `AttenuationConfig.ttl_seconds` + /// on the `DelegationPayload`. + #[serde(default = "default_ttl_seconds")] + pub default_ttl_seconds: u64, +} + +/// Where the root public key is loaded from. Three modes: +/// +/// * **`hex`** — 32-byte Ed25519 public key encoded as 64 hex +/// characters. Convenient for testing and dev configs. +/// * **`file`** — path to a file containing the raw 32-byte key +/// (binary) or its hex encoding (with optional newline). The +/// resolver auto-detects which. +/// * **`bytes`** — inline 32-byte raw key. Rarely used directly +/// in YAML (operators prefer hex or file) but available for +/// programmatic construction. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PublicKeySource { + Hex { hex: String }, + File { path: PathBuf }, + Bytes { bytes: Vec }, +} + +fn default_outbound_header() -> String { + "Authorization".to_string() +} + +fn default_ttl_seconds() -> u64 { + 300 +} + +impl PublicKeySource { + /// Turn the serializable source into a runtime `PublicKey`. + /// Returns a string error so the caller wraps in + /// `PluginError::Config` with context. + pub fn resolve(&self) -> Result { + match self { + Self::Hex { hex } => { + let bytes = hex::decode(hex.trim()) + .map_err(|e| format!("public_key.hex isn't valid hex: {e}"))?; + Self::bytes_to_public_key(&bytes) + } + Self::Bytes { bytes } => Self::bytes_to_public_key(bytes), + Self::File { path } => { + let raw = std::fs::read(path).map_err(|e| { + format!("public_key file '{}' unreadable: {e}", path.display()) + })?; + // File might be raw 32 bytes OR a hex string (with + // optional whitespace). Try raw first; fall back to + // hex if the length doesn't match. + if raw.len() == 32 { + Self::bytes_to_public_key(&raw) + } else { + // Treat as hex with possible whitespace. + let as_str = std::str::from_utf8(&raw).map_err(|e| { + format!( + "public_key file '{}' isn't 32 raw bytes or valid \ + UTF-8 hex: {e}", + path.display() + ) + })?; + let trimmed = as_str.trim(); + let bytes = hex::decode(trimmed).map_err(|e| { + format!( + "public_key file '{}' isn't valid hex: {e}", + path.display() + ) + })?; + Self::bytes_to_public_key(&bytes) + } + } + } + } + + fn bytes_to_public_key(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(format!( + "Ed25519 public key must be 32 bytes; got {}", + bytes.len() + )); + } + PublicKey::from_bytes(bytes, biscuit_auth::Algorithm::Ed25519) + .map_err(|e| format!("public key bytes not a valid Ed25519 key: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use biscuit_auth::KeyPair; + + #[test] + fn hex_source_resolves() { + let kp = KeyPair::new(); + let pub_hex = hex::encode(kp.public().to_bytes()); + let src = PublicKeySource::Hex { hex: pub_hex }; + assert!(src.resolve().is_ok()); + } + + #[test] + fn hex_source_rejects_wrong_length() { + let src = PublicKeySource::Hex { + hex: "deadbeef".into(), // 4 bytes — wrong length + }; + let err = src.resolve().unwrap_err(); + assert!(err.contains("32 bytes")); + } + + #[test] + fn hex_source_rejects_garbage() { + let src = PublicKeySource::Hex { + hex: "not hex".into(), + }; + let err = src.resolve().unwrap_err(); + assert!(err.contains("hex")); + } + + #[test] + fn config_deserializes() { + let kp = KeyPair::new(); + let pub_hex = hex::encode(kp.public().to_bytes()); + let raw = serde_json::json!({ + "root_public_key": { "kind": "hex", "hex": pub_hex }, + "default_outbound_header": "X-AIP-Token", + "default_ttl_seconds": 60, + }); + let cfg: BiscuitDelegatorConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.default_outbound_header, "X-AIP-Token"); + assert_eq!(cfg.default_ttl_seconds, 60); + assert!(cfg.root_public_key.resolve().is_ok()); + } +} diff --git a/crates/apl-delegator-biscuit/src/delegator.rs b/crates/apl-delegator-biscuit/src/delegator.rs new file mode 100644 index 00000000..9961886d --- /dev/null +++ b/crates/apl-delegator-biscuit/src/delegator.rs @@ -0,0 +1,279 @@ +// Location: ./crates/apl-delegator-biscuit/src/delegator.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `BiscuitDelegator` — `HookHandler` that +// performs biscuit-auth capability-token attenuation. +// +// # Flow +// +// 1. Decode `payload.bearer_token()` as base64 → biscuit bytes. +// 2. Parse + verify the inbound biscuit against the configured +// root public key (`Biscuit::from(bytes, root_public_key)`). +// 3. Build a delegation block carrying the route's narrowing +// constraints: +// * `delegated_to("")` fact +// * `audience("")` fact +// * `check if operation("")` for each required permission +// * `check if time($t), $t <= ` time-bound +// 4. Append the block via `biscuit.append(block_builder)`. Biscuit +// generates an ephemeral signing keypair internally — the +// verifier walks the chain to validate. +// 5. Serialize the new biscuit (now with one more block) to +// base64 → `RawDelegatedToken`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime errors → `PluginResult::deny(PluginViolation::new(code, +// reason))`: +// * `delegation.bad_request` — missing bearer token / target audience +// * `delegation.token_invalid` — base64 decode failed or biscuit +// verification failed (wrong key, +// tampered signature, malformed) +// * `delegation.attenuation_failed` — block construction failed +// (Datalog syntax error) + +use async_trait::async_trait; +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 super::config::BiscuitDelegatorConfig; + +/// Biscuit-mediated `TokenDelegate` handler. +pub struct BiscuitDelegator { + cfg: PluginConfig, + typed: BiscuitDelegatorConfig, + /// Pre-resolved root public key — verifying every inbound + /// biscuit's authority block. + root_public_key: PublicKey, +} + +impl std::fmt::Debug for BiscuitDelegator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BiscuitDelegator") + .field("cfg", &self.cfg.name) + .field("default_outbound_header", &self.typed.default_outbound_header) + .field("default_ttl_seconds", &self.typed.default_ttl_seconds) + .field("root_public_key", &"") + .finish() + } +} + +impl BiscuitDelegator { + /// Build from `PluginConfig`. Parses `cfg.config` into + /// [`BiscuitDelegatorConfig`] and resolves the root public key. + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: BiscuitDelegatorConfig = serde_json::from_value(raw.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) config parse failed: {e}", + cfg.name + ), + }) + })?; + + let root_public_key = typed.root_public_key.resolve().map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) root_public_key: {e}", + cfg.name + ), + }) + })?; + + Ok(Self { + cfg, + typed, + root_public_key, + }) + } + + /// Resolve the effective TTL — route hint wins if shorter than + /// the configured default. + fn effective_ttl_seconds(&self, payload: &DelegationPayload) -> u64 { + match payload.route_attenuation().and_then(|a| a.ttl_seconds) { + Some(hint) => hint.min(self.typed.default_ttl_seconds), + None => self.typed.default_ttl_seconds, + } + } +} + +#[async_trait] +impl Plugin for BiscuitDelegator { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for BiscuitDelegator { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let bearer = payload.bearer_token(); + if bearer.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "DelegationPayload carried an empty bearer_token", + )); + } + let audience = payload.target_audience().unwrap_or("").to_string(); + if audience.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "target_audience missing — biscuit attenuation requires \ + an audience to scope the delegation block", + )); + } + + // 1. Decode + parse + verify inbound biscuit. + // `Biscuit::from_base64` handles both URL-safe and + // standard base64 variants internally. + let biscuit = match Biscuit::from_base64(bearer, self.root_public_key) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.token_invalid", + format!( + "inbound biscuit verification failed against configured \ + root public key: {e}" + ), + )); + } + }; + + // 2. Build the delegation block. + let ttl_secs = self.effective_ttl_seconds(payload); + let expires_at_unix = (Utc::now() + + chrono::Duration::seconds(ttl_secs as i64)) + .timestamp(); + + // Build the delegation block as a Datalog string. biscuit + // parses + validates the Datalog at parse time. Building + // the source as a single string and parsing once is simpler + // than the typed Fact/Term builder API. + // + // Quote-escape any embedded `"` in user-supplied values so a + // malicious target_name or required_permission can't escape + // the Datalog string literal and inject extra clauses. + let mut datalog = String::new(); + datalog.push_str(&format!( + r#"delegated_to("{}");"#, + escape_datalog_string(payload.target_name()) + )); + datalog.push_str(&format!( + r#"audience("{}");"#, + escape_datalog_string(&audience) + )); + for perm in payload.required_permissions() { + datalog.push_str(&format!( + r#"check if operation("{}");"#, + escape_datalog_string(perm) + )); + } + // Time-bound check — token unusable past expires_at. + datalog.push_str(&format!( + "check if time($t), $t <= {expires_at_unix};" + )); + + // biscuit-auth 6's `BlockBuilder::code` consumes the + // builder and returns a new one on success (or an error if + // the Datalog source is malformed). + let builder = match BlockBuilder::new().code(datalog.as_str()) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("delegation block Datalog parse failed: {e}"), + )); + } + }; + + // 3. Append the block. Biscuit generates an ephemeral + // Ed25519 keypair internally for the new block; the + // verifier walks the chain to validate. + let attenuated = match biscuit.append(builder) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("biscuit append failed: {e}"), + )); + } + }; + + // 4. Serialize. + let new_bytes = match attenuated.to_base64() { + Ok(s) => s, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("could not serialize attenuated biscuit: {e}"), + )); + } + }; + + // 5. Build RawDelegatedToken. + let scopes: Vec = { + let mut s: Vec = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !s.contains(cap) { + s.push(cap.clone()); + } + } + } + s + }; + let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs as i64); + let token = RawDelegatedToken::new( + new_bytes, + self.typed.default_outbound_header.clone(), + audience, + scopes, + expires_at, + ); + + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + updated.minted_at = Some(Utc::now()); + updated.metadata.insert( + "delegator".into(), + serde_json::Value::String("biscuit".into()), + ); + + PluginResult::modify_payload(updated) + } +} + +/// Escape `"` and `\` in a Datalog string literal so user-supplied +/// values (target name, requested scopes) can't break out of the +/// surrounding `"..."` and inject extra Datalog clauses. Belt-and- +/// suspenders — biscuit's parser would likely reject malformed +/// output but the explicit escape avoids relying on parser behavior. +fn escape_datalog_string(s: &str) -> String { + s.replace('\\', r"\\").replace('"', "\\\"") +} diff --git a/crates/apl-delegator-biscuit/src/lib.rs b/crates/apl-delegator-biscuit/src/lib.rs new file mode 100644 index 00000000..fa5b3e9a --- /dev/null +++ b/crates/apl-delegator-biscuit/src/lib.rs @@ -0,0 +1,33 @@ +// Location: ./crates/apl-delegator-biscuit/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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- +// 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 +// requested permissions + audience + TTL, and returns the new +// base64-encoded biscuit as the `RawDelegatedToken`. +// +// # AIP Chained Mode +// +// The output of this delegator is structurally what +// `draft-prakash-aip-00` calls a "Chained Mode" token — authority +// 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. +// +// Sub-step A scope: module structure only. Real implementation in +// sub-step B; integration tests in sub-step C. + +pub mod config; +pub mod delegator; + +pub use config::{BiscuitDelegatorConfig, PublicKeySource}; +pub use delegator::BiscuitDelegator; diff --git a/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs b/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs new file mode 100644 index 00000000..7230da62 --- /dev/null +++ b/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs @@ -0,0 +1,316 @@ +// Location: ./crates/apl-delegator-biscuit/tests/biscuit_e2e.rs +// Copyright 2025 +// 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. + +use std::sync::Arc; + +use biscuit_auth::{ + builder::{AuthorizerBuilder, BlockBuilder}, + Biscuit, KeyPair, +}; + +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +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 apl_delegator_biscuit::BiscuitDelegator; + +use serde_json::json; + +// ===================================================================== +// Fixtures +// ===================================================================== + +struct Roots { + keypair: KeyPair, +} + +fn roots() -> &'static Roots { + use std::sync::OnceLock; + static ROOTS: OnceLock = OnceLock::new(); + ROOTS.get_or_init(|| Roots { + keypair: KeyPair::new(), + }) +} + +/// 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`. +fn mint_inbound_biscuit(authority_datalog: &str) -> String { + let builder = BlockBuilder::new() + .code(authority_datalog) + .expect("authority Datalog parses"); + Biscuit::builder() + .merge(builder) + .build(&roots().keypair) + .expect("biscuit builds") + .to_base64() + .expect("biscuit serializes") +} + +fn plugin_config() -> PluginConfig { + 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() + } +} + +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 +} + +fn build_payload(inbound: String, target: &str, audience: &str, perms: &[&str]) -> DelegationPayload { + DelegationPayload::new(inbound, target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(perms.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 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: inbound biscuit + delegation request → attenuated +/// biscuit that still verifies against the root key and carries +/// the expected facts/checks in the new block. +#[tokio::test] +async fn happy_path_attenuates_biscuit() { + let inbound = mint_inbound_biscuit( + r#" + right("read"); + right("audit"); + "#, + ); + + let mgr = build_manager().await; + let payload = build_payload( + inbound.clone(), + "get_compensation", + "https://hr.example.com", + &["read"], + ); + + 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 minted = final_payload + .delegated_token + .as_ref() + .expect("delegated_token populated"); + + assert_eq!(minted.audience, "https://hr.example.com"); + assert_eq!(minted.outbound_header, "Authorization"); + // The minted bytes are a NEW (longer) biscuit — appending a + // block grows the serialized form. + assert_ne!(&*minted.token, &inbound); + assert!(minted.token.len() > inbound.len()); + + // Verify the chain: the attenuated biscuit must still validate + // against our root public key. + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()) + .expect("attenuated biscuit verifies against root"); + + // The new biscuit should have one more block than the original. + let original = Biscuit::from_base64(&inbound, roots().keypair.public()) + .expect("inbound verifies"); + assert_eq!(attenuated.block_count(), original.block_count() + 1); + + // Authorize against the matching operation — should succeed + // because the delegation block adds `check if operation("read")` + // and the verifier provides that fact. The Datalog `time(...)` + // fact must be in the past relative to our `check if time(...) + // <= expires_at` predicate, so we pick a tiny value. + let mut authorizer = AuthorizerBuilder::new() + .code(r#"operation("read"); time(0); allow if true;"#) + .expect("authorizer policy parses") + .build(&attenuated) + .expect("authorizer builds against attenuated biscuit"); + authorizer + .authorize() + .expect("authorizer should allow with matching operation"); + + // Mode = OnBehalfOfUser per the biscuit attenuation convention. + assert!(matches!( + final_payload.delegation_mode, + Some(DelegationMode::OnBehalfOfUser), + )); + + // Metadata records the delegator family — useful for audit. + assert_eq!( + final_payload.metadata.get("delegator"), + Some(&json!("biscuit")), + ); +} + +/// Verifier presents a non-matching operation → the +/// `check if operation("read")` from our delegation block fails +/// → authorizer denies. Pins the scope-narrowing invariant: the +/// downstream service can't use the minted token for operations +/// it wasn't granted. +#[tokio::test] +async fn attenuated_token_denies_wrong_operation() { + let inbound = mint_inbound_biscuit(r#"right("read");"#); + let mgr = build_manager().await; + let payload = build_payload( + inbound, + "get_compensation", + "https://hr.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(result.continue_processing); + let final_payload = DelegationPayload::from_pipeline_result(&result).unwrap(); + let minted = final_payload.delegated_token.as_ref().unwrap(); + + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()).unwrap(); + // Verifier presents `operation("write")` — should fail because + // our delegation block checks for `operation("read")`. + let mut authorizer = AuthorizerBuilder::new() + .code(r#"operation("write"); time(0); allow if true;"#) + .unwrap() + .build(&attenuated) + .unwrap(); + let res = authorizer.authorize(); + assert!( + res.is_err(), + "attenuated token should deny `write` when delegation only allows `read`", + ); +} + +/// Inbound biscuit signed by a DIFFERENT root key than our config +/// trusts → verification fails at parse time → `delegation.token_invalid`. +#[tokio::test] +async fn wrong_root_key_rejects() { + // Mint with a foreign keypair — NOT the one our delegator trusts. + let foreign = KeyPair::new(); + let foreign_biscuit = Biscuit::builder() + .merge(BlockBuilder::new().code(r#"right("read");"#).unwrap()) + .build(&foreign) + .unwrap() + .to_base64() + .unwrap(); + + let mgr = build_manager().await; + let payload = build_payload( + foreign_biscuit, + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.token_invalid"); +} + +/// Empty bearer token → fast-fail input validation, no biscuit +/// parsing attempted. +#[tokio::test] +async fn empty_bearer_token_rejects() { + let mgr = build_manager().await; + let payload = DelegationPayload::new("", "tool") + .with_target_audience("https://downstream.example.com"); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.bad_request"); + assert!(v.reason.contains("empty bearer_token")); +} + +/// Missing target audience — biscuit attenuation needs an audience +/// to scope the delegation block. +#[tokio::test] +async fn missing_audience_rejects() { + let inbound = mint_inbound_biscuit(r#"right("read");"#); + let mgr = build_manager().await; + let payload = DelegationPayload::new(inbound, "tool"); // no audience + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.bad_request"); + assert!(v.reason.contains("target_audience")); +} + +/// Garbage (non-biscuit) bearer token → parse / verify fails → +/// `delegation.token_invalid`. +#[tokio::test] +async fn malformed_bearer_token_rejects() { + let mgr = build_manager().await; + let payload = build_payload( + "this-is-not-a-biscuit".to_string(), + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.token_invalid"); +} diff --git a/crates/apl-delegator-oauth/Cargo.toml b/crates/apl-delegator-oauth/Cargo.toml new file mode 100644 index 00000000..f4956282 --- /dev/null +++ b/crates/apl-delegator-oauth/Cargo.toml @@ -0,0 +1,69 @@ +# Location: ./crates/apl-delegator-oauth/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-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 `apl-delegator-biscuit` +# +# - **`apl-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. +# - **`apl-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 = "apl-delegator-oauth" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# `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 } +serde_yaml = { workspace = true } +serde_urlencoded = "0.7" +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } + +# 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" diff --git a/crates/apl-delegator-oauth/src/config.rs b/crates/apl-delegator-oauth/src/config.rs new file mode 100644 index 00000000..0a4856b2 --- /dev/null +++ b/crates/apl-delegator-oauth/src/config.rs @@ -0,0 +1,158 @@ +// Location: ./crates/apl-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, +} + +/// 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_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"); + } + + #[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/crates/apl-delegator-oauth/src/delegator.rs b/crates/apl-delegator-oauth/src/delegator.rs new file mode 100644 index 00000000..09508483 --- /dev/null +++ b/crates/apl-delegator-oauth/src/delegator.rs @@ -0,0 +1,474 @@ +// Location: ./crates/apl-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= +// 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::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, 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 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"; + +/// 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, +} + +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 '{}' (apl-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 '{}' (apl-delegator-oauth) config parse failed: {e}", + cfg.name + ), + }) + })?; + + if typed.token_endpoint.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-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 '{}' (apl-delegator-oauth): token_endpoint {e}", + cfg.name, + ), + })); + } + if typed.client_id.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-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 '{}' (apl-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 '{}' (apl-delegator-oauth) HTTP client build failed: {e}", + cfg.name + ), + }) + })?; + + Ok(Self { + cfg, + typed, + client_secret: Zeroizing::new(secret), + http, + }) + } + + /// 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(" ") + } +} + +/// 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 { + let bearer = payload.bearer_token(); + if bearer.is_empty() { + 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); + + // Build the form-encoded body. RFC 8693 §2.1. + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), + ("subject_token", bearer), + ("subject_token_type", &self.typed.subject_token_type), + ("audience", audience), + ]; + if !scope.is_empty() { + form.push(("scope", &scope)); + } + + // 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); + + 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(DelegationMode::OnBehalfOfUser); + 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) + } +} + +// 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")); + } +} diff --git a/crates/apl-delegator-oauth/src/factory.rs b/crates/apl-delegator-oauth/src/factory.rs new file mode 100644 index 00000000..b6a6c167 --- /dev/null +++ b/crates/apl-delegator-oauth/src/factory.rs @@ -0,0 +1,59 @@ +// Location: ./crates/apl-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/crates/apl-delegator-oauth/src/lib.rs b/crates/apl-delegator-oauth/src/lib.rs new file mode 100644 index 00000000..4e81c1e1 --- /dev/null +++ b/crates/apl-delegator-oauth/src/lib.rs @@ -0,0 +1,29 @@ +// Location: ./crates/apl-delegator-oauth/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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`. +// +// Sub-step A scope: data shapes + module structure only. Actual +// HTTP exchange logic in sub-step B; mock-IdP integration tests in +// sub-step C. + +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/crates/apl-delegator-oauth/tests/oauth_e2e.rs b/crates/apl-delegator-oauth/tests/oauth_e2e.rs new file mode 100644 index 00000000..ff10351b --- /dev/null +++ b/crates/apl-delegator-oauth/tests/oauth_e2e.rs @@ -0,0 +1,382 @@ +// Location: ./crates/apl-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 + +use std::sync::Arc; + +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +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 apl_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; +} diff --git a/crates/apl-identity-jwt/Cargo.toml b/crates/apl-identity-jwt/Cargo.toml new file mode 100644 index 00000000..65dcf09b --- /dev/null +++ b/crates/apl-identity-jwt/Cargo.toml @@ -0,0 +1,92 @@ +# Location: ./crates/apl-identity-jwt/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-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. +# +# # Why this exists alongside `apl-cedarling` +# +# Cedarling's JWT validation is bundled with Cedar policy +# evaluation — it doesn't expose validated identity as a separate +# data product. For deployments that want JWT validation without +# (or before) the Cedar policy step, this crate fills the gap. +# Lightweight (~5-15 transitive deps) vs Cedarling (~200). +# +# # Default-members +# +# This crate IS in the workspace's default-members — the dep tree +# is small enough that it doesn't slow down default builds. Compare +# to `apl-cedarling`, which is excluded. + +[package] +name = "apl-identity-jwt" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# `jsonwebtoken` is the de facto JWT library for Rust. ~5 transitive +# deps (ring, base64, serde, pem). Supports RS256/RS384/RS512, +# ES256/ES384, EdDSA, HS256/HS384/HS512. Default features include +# `use_pem` (load DecodingKey from PEM strings) which we want. +jsonwebtoken = "9" + +# `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 } +serde_yaml = { workspace = true } +thiserror = { 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. apl-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"] } +# rsa 0.9's `RsaPrivateKey::new(&mut rng, bits)` takes an rng +# implementing `rand_core::CryptoRngCore` — `rand::thread_rng()` +# satisfies that. Test-only dep. +rand = "0.8" diff --git a/crates/apl-identity-jwt/src/claim_map.rs b/crates/apl-identity-jwt/src/claim_map.rs new file mode 100644 index 00000000..f1fbd5e2 --- /dev/null +++ b/crates/apl-identity-jwt/src/claim_map.rs @@ -0,0 +1,401 @@ +// Location: ./crates/apl-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::Workload`. +/// +/// 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)) + .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/crates/apl-identity-jwt/src/config.rs b/crates/apl-identity-jwt/src/config.rs new file mode 100644 index 00000000..37a70da7 --- /dev/null +++ b/crates/apl-identity-jwt/src/config.rs @@ -0,0 +1,511 @@ +// Location: ./crates/apl-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 (Slice B). + /// + /// **v0 caveat (still open after Slice A):** + /// + /// * No automatic rotation — the store is bound at initialize + /// time. Slice B adds a background refresh task so IdP key + /// rolls don't require 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/crates/apl-identity-jwt/src/factory.rs b/crates/apl-identity-jwt/src/factory.rs new file mode 100644 index 00000000..3306c4db --- /dev/null +++ b/crates/apl-identity-jwt/src/factory.rs @@ -0,0 +1,60 @@ +// Location: ./crates/apl-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/crates/apl-identity-jwt/src/lib.rs b/crates/apl-identity-jwt/src/lib.rs new file mode 100644 index 00000000..2dff2158 --- /dev/null +++ b/crates/apl-identity-jwt/src/lib.rs @@ -0,0 +1,61 @@ +// Location: ./crates/apl-identity-jwt/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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). Designed as the +// lightweight identity path that pairs with `apl-cedarling`'s +// PDP role — operators wanting both run identity here, policy +// gating through `cedarling:` steps. +// +// Sub-step A scope: data shapes + module structure only. Actual +// validation logic in sub-step B; multi-issuer + key rotation in +// sub-step C; integration tests in sub-step D. +// +// # 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 +// +// - **`apl-identity-jwt`** (this crate) — JWT-only flow. +// Lightweight, ~5-15 transitive deps. The default choice for +// "validate a Bearer token, extract identity." +// - **`apl-cedarling`** as identity (deferred) — Cedarling's API +// doesn't expose validated entities to callers, so we deferred +// wiring it as an IdentityResolveHandler. Use this crate for +// validation + a `cedarling:` step early in the route policy +// block if you want policy-driven identity gating. +// - **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/crates/apl-identity-jwt/src/resolver.rs b/crates/apl-identity-jwt/src/resolver.rs new file mode 100644 index 00000000..b7c517f1 --- /dev/null +++ b/crates/apl-identity-jwt/src/resolver.rs @@ -0,0 +1,834 @@ +// Location: ./crates/apl-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 '{}' (apl-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 '{}' (apl-identity-jwt) config parse failed: {e}", + cfg.name + ), + }) + })?; + + if typed.trusted_issuers.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-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 '{}' (apl-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 '{}' (apl-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 '{}' (apl-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 '{}' (apl-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 '{}' (apl-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 (Slice B):** 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::Workload => 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. + 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(), TokenKind::Jwt), + ); + 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) + } +} + +// ===================================================================== +// Internal helpers +// ===================================================================== + +/// 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 introduced by Slice A / B. +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/crates/apl-identity-jwt/src/trusted_issuer.rs b/crates/apl-identity-jwt/src/trusted_issuer.rs new file mode 100644 index 00000000..a5acf6d4 --- /dev/null +++ b/crates/apl-identity-jwt/src/trusted_issuer.rs @@ -0,0 +1,198 @@ +// Location: ./crates/apl-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 (Slice B). Today every +/// construction path populates at least one slot before the store +/// is reachable from the resolver. +/// +/// # Update discipline (Slice B 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 + /// (Slice B); 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/crates/apl-identity-jwt/tests/jwks_url_e2e.rs b/crates/apl-identity-jwt/tests/jwks_url_e2e.rs new file mode 100644 index 00000000..b06e4518 --- /dev/null +++ b/crates/apl-identity-jwt/tests/jwks_url_e2e.rs @@ -0,0 +1,750 @@ +// Location: ./crates/apl-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 apl_identity_jwt::{DecodingKeySource, JwtIdentityResolver}; + +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use mockito::Server; +use rsa::pkcs1::EncodeRsaPublicKey; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +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 = rand::thread_rng(); + 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 = rand::thread_rng(); + 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"); +} + +// ===================================================================== +// P0-5 Slice A: 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 = rand::thread_rng(); + 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 = rand::thread_rng(); + 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:?}", + ); +} + +// ===================================================================== +// P0-5 Slice B: 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 = rand::thread_rng(); + 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 = rand::thread_rng(); + 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/crates/apl-identity-jwt/tests/jwt_e2e.rs b/crates/apl-identity-jwt/tests/jwt_e2e.rs new file mode 100644 index 00000000..b18a6c3c --- /dev/null +++ b/crates/apl-identity-jwt/tests/jwt_e2e.rs @@ -0,0 +1,298 @@ +// Location: ./crates/apl-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 apl_identity_jwt::JwtIdentityResolver; + +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +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 = rand::thread_rng(); + 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() + } +} + +/// Build the PluginManager + register the resolver + initialize. +/// All four scenarios share this skeleton. +async fn build_manager() -> Arc { + let cfg = resolver_plugin_config(); + 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 { + let mgr = build_manager().await; + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(token, TokenSource::Bearer), + 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)); +} + +/// 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/crates/apl-pdp-cedar-direct/Cargo.toml b/crates/apl-pdp-cedar-direct/Cargo.toml new file mode 100644 index 00000000..4072a665 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/Cargo.toml @@ -0,0 +1,63 @@ +# Location: ./crates/apl-pdp-cedar-direct/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-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). +# +# When to use this crate vs `apl-pdp-cedarling`: +# +# - **cedar-direct** — host already has identity validated (via gateway, +# SPIFFE, prior plugin, or hand-rolled JWT validation); policies are +# loaded as text/files at startup and don't change at runtime; smallest +# dep tree; ~5 transitive crates instead of 200+. +# - **cedarling** — host wants JWT validation + claims-to-entity mapping +# + centralized policy management (Janssen Lock Server) all in one +# library. +# +# Both crates speak Cedar 4.x; their decisions on identical policy + entity +# + request inputs are byte-identical. The difference is what's around the +# Cedar engine. + +[package] +name = "apl-pdp-cedar-direct" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +# Permissive caret spec — `"4"` means "any 4.x that Cargo can find." +# We rely on Cargo's standard version resolution to dedup with +# cedarling's `cedar-policy = "4.9.0"` (also caret), so both crates +# end up compiling against the same `cedar-policy` version (currently +# 4.11 — bumps automatically when either side allows a newer 4.x). +# This matters because mixing `cedar_policy@4.9::Decision` and +# `cedar_policy@4.11::Decision` in the same workspace would produce +# distinct types Rust treats as incompatible. +# +# 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" +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { 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 = { path = "../apl-cmf" } +apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs b/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs new file mode 100644 index 00000000..ad91dd76 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs @@ -0,0 +1,61 @@ +// Location: ./crates/apl-pdp-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/crates/apl-pdp-cedar-direct/src/decision.rs b/crates/apl-pdp-cedar-direct/src/decision.rs new file mode 100644 index 00000000..4391f98c --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/decision.rs @@ -0,0 +1,127 @@ +// Location: ./crates/apl-pdp-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 — see +// `docs/specs/cedar-context-contract.md`. +// +// # 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/crates/apl-pdp-cedar-direct/src/entities.rs b/crates/apl-pdp-cedar-direct/src/entities.rs new file mode 100644 index 00000000..3ae3cfcc --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/entities.rs @@ -0,0 +1,253 @@ +// Location: ./crates/apl-pdp-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 + )) + }) +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// 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/crates/apl-pdp-cedar-direct/src/error.rs b/crates/apl-pdp-cedar-direct/src/error.rs new file mode 100644 index 00000000..3b640fc2 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/error.rs @@ -0,0 +1,67 @@ +// Location: ./crates/apl-pdp-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/crates/apl-pdp-cedar-direct/src/factory.rs b/crates/apl-pdp-cedar-direct/src/factory.rs new file mode 100644 index 00000000..dd5c4ba3 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/factory.rs @@ -0,0 +1,54 @@ +// Location: ./crates/apl-pdp-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: +// apl: +// 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/crates/apl-pdp-cedar-direct/src/lib.rs b/crates/apl-pdp-cedar-direct/src/lib.rs new file mode 100644 index 00000000..606576aa --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/lib.rs @@ -0,0 +1,114 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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. See `docs/specs/cedar-context-contract.md` for the +// authoritative shape. +// +// # 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/crates/apl-pdp-cedar-direct/src/request.rs b/crates/apl-pdp-cedar-direct/src/request.rs new file mode 100644 index 00000000..4c952aed --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/request.rs @@ -0,0 +1,216 @@ +// Location: ./crates/apl-pdp-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 documented in `docs/specs/cedar-context-contract.md`. +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/crates/apl-pdp-cedar-direct/src/resolver.rs b/crates/apl-pdp-cedar-direct/src/resolver.rs new file mode 100644 index 00000000..348ed2ff --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/resolver.rs @@ -0,0 +1,301 @@ +// Location: ./crates/apl-pdp-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; + +/// 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()))?; + + // ----- policy source ----- + 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()))?; + + // ----- optional schema ----- + 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, + }; + + // ----- optional dialect override ----- + 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, + }; + + 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)) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +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/crates/apl-pdp-cedar-direct/src/template.rs b/crates/apl-pdp-cedar-direct/src/template.rs new file mode 100644 index 00000000..1479661e --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/template.rs @@ -0,0 +1,281 @@ +// Location: ./crates/apl-pdp-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/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs b/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs new file mode 100644 index 00000000..05400a4b --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs @@ -0,0 +1,220 @@ +// Location: ./crates/apl-pdp-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 apl_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/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs new file mode 100644 index 00000000..76ec2c11 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs @@ -0,0 +1,166 @@ +// Location: ./crates/apl-pdp-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 apl_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: + policy: + - 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())], + 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/crates/apl-pii-scanner/Cargo.toml b/crates/apl-pii-scanner/Cargo.toml new file mode 100644 index 00000000..89369aae --- /dev/null +++ b/crates/apl-pii-scanner/Cargo.toml @@ -0,0 +1,28 @@ +# Location: ./crates/apl-pii-scanner/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-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 = "apl-pii-scanner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pii-scanner/src/config.rs b/crates/apl-pii-scanner/src/config.rs new file mode 100644 index 00000000..e0ecdeb7 --- /dev/null +++ b/crates/apl-pii-scanner/src/config.rs @@ -0,0 +1,85 @@ +// Location: ./crates/apl-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/crates/apl-pii-scanner/src/factory.rs b/crates/apl-pii-scanner/src/factory.rs new file mode 100644 index 00000000..66f46995 --- /dev/null +++ b/crates/apl-pii-scanner/src/factory.rs @@ -0,0 +1,70 @@ +// Location: ./crates/apl-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 '{}' (apl-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/crates/apl-pii-scanner/src/lib.rs b/crates/apl-pii-scanner/src/lib.rs new file mode 100644 index 00000000..6f5ee532 --- /dev/null +++ b/crates/apl-pii-scanner/src/lib.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-pii-scanner/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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/crates/apl-pii-scanner/src/scanner.rs b/crates/apl-pii-scanner/src/scanner.rs new file mode 100644 index 00000000..3b18c839 --- /dev/null +++ b/crates/apl-pii-scanner/src/scanner.rs @@ -0,0 +1,322 @@ +// Location: ./crates/apl-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 '{}' (apl-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 '{}' (apl-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}' (apl-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/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 2885700f..abbd5e62 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -29,3 +29,12 @@ futures = { 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 = { path = "../cpex-orchestration" } diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs index 12a8ac5e..454ec7b2 100644 --- a/crates/cpex-core/src/cmf/constants.rs +++ b/crates/cpex-core/src/cmf/constants.rs @@ -63,3 +63,34 @@ 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"; + +// --------------------------------------------------------------------------- +// 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"; diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index b2bad350..6a13a2ec 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -66,6 +66,20 @@ impl Message { } } + /// 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. diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 89d962a2..6eca89a8 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -159,6 +159,16 @@ pub struct GlobalConfig { /// Keys are `tool`, `resource`, `prompt`, `llm`. #[serde(default)] pub defaults: HashMap, + + /// Global identity dispatch list. Inherited by every route as + /// the first layer of identity resolution. Routes can append + /// to it (additive, the default) or replace it (with + /// `identity.replace_inherited: true` on the route). + /// + /// Same YAML shape as the route-level `identity:` block — see + /// `RouteEntry.identity` for the accepted forms. + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, } // --------------------------------------------------------------------------- @@ -181,6 +191,14 @@ pub struct PolicyGroup { /// Plugin references to activate when this group matches. #[serde(default)] pub plugins: Vec, + + /// Identity dispatch list contributed by this tag bundle. + /// Inherited by routes that carry this tag in `meta.tags`, + /// stacked between the global identity (first) and the route's + /// own identity (last). Same YAML shape as the route-level + /// `identity:` block. + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, } // --------------------------------------------------------------------------- @@ -262,6 +280,161 @@ pub struct RouteEntry { /// Plugin references to activate for this route. #[serde(default)] pub plugins: Vec, + + /// Identity-resolve dispatch list for this route. **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 — `identity:` 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 + /// identity: + /// - corp-jwt + /// - spiffe-attestor + /// + /// # Object form — when the override flag is needed + /// identity: + /// replace_inherited: true + /// steps: + /// - legacy-basic-auth + /// ``` + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, +} + +// --------------------------------------------------------------------------- +// Custom Deserialize for RouteEntry.identity +// --------------------------------------------------------------------------- + +/// Deserialize `identity:` 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( + "`identity:` object form requires `steps:` (a list of \ + identity steps); did you mean to write the list form?", + ) + })?; + let items = steps_val + .as_sequence() + .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))? + .clone(); + (replace_inherited, items) + } + _ => { + return Err(D::Error::custom( + "`identity:` 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:`)" + )), + } } // --------------------------------------------------------------------------- @@ -581,6 +754,107 @@ pub fn resolve_plugins_for_entity( deduped } +/// Resolve the identity-resolve dispatch list for a specific +/// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`] +/// — consults `global.identity`, tag-bundle `identity` blocks, and +/// the route's own `identity:` 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 tags (static + + // any runtime tags would compose here too, but resolve_* + // currently doesn't take runtime tags as a parameter for + // identity — symmetry with the existing `plugins:` resolver + // would extend the signature; deferred until needed). + if let Some(route) = route { + if let Some(meta) = &route.meta { + for tag in &meta.tags { + 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 { @@ -1294,4 +1568,439 @@ routes: let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap(); assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); } + + // ---- route-level `identity:` block ---- + + #[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 + identity: + - 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 + identity: + 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 + identity: + - 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 + identity: + - 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 + identity: + 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 + identity: + 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 + identity: + - "" +"#; + let err = parse_config(yaml).expect_err("empty step name should fail"); + let msg = format!("{err}"); + assert!(msg.contains("empty"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_scalar_shape_errors() { + let yaml = r#" +routes: + - tool: bad + identity: 42 +"#; + let err = parse_config(yaml).expect_err("scalar identity should fail"); + let msg = format!("{err}"); + assert!(msg.contains("list of steps"), "got: {msg}"); + } + + // ---- resolve_identity_plugins_for_route ---- + + #[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 + identity: + - 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 + identity: + - 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 + identity: + - 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")); + } + + // ---- Slice C: global + tag-bundle inheritance ---- + + #[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: + identity: + - 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: + identity: + - corp-jwt +routes: + - tool: get_weather + identity: + - 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: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml +routes: + - tool: get_compensation + meta: + tags: [finance] + identity: + - 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: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml +routes: + - tool: legacy_endpoint + meta: + tags: [finance] + identity: + 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: + identity: + - corp-jwt +routes: + - tool: anonymous_endpoint + identity: + 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: + identity: + - 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 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 + identity: + - 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()); + } } diff --git a/crates/cpex-core/src/delegation/hook.rs b/crates/cpex-core/src/delegation/hook.rs new file mode 100644 index 00000000..9b001514 --- /dev/null +++ b/crates/cpex-core/src/delegation/hook.rs @@ -0,0 +1,86 @@ +// 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 new file mode 100644 index 00000000..af86a83c --- /dev/null +++ b/crates/cpex-core/src/delegation/mod.rs @@ -0,0 +1,21 @@ +// 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. +// +// Sub-step A scope: data shapes + host helpers — no executor +// wiring (that's free via `mgr.invoke_named::`), +// no TokenCacheControl trait (that lands in a follow-up slice with +// the cache infrastructure). + +pub mod hook; +pub mod payload; + +pub use hook::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}; +pub use payload::{AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType}; diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs new file mode 100644 index 00000000..6328d088 --- /dev/null +++ b/crates/cpex-core/src/delegation/payload.rs @@ -0,0 +1,694 @@ +// 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` (slice 2): +// +// * **Input** (private — host-supplied, never mutated by handlers) — +// `bearer_token`, `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 (slice 2) 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 in this slice. The spec describes a `TokenCacheControl` trait +// at §9.8 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 lives one slice 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; +use crate::extensions::{ + DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken, +}; +use crate::impl_plugin_payload; + +/// 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 { + // ----- Input (private — caller-supplied, never mutated by handlers) ----- + /// 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, + + /// 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, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// 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. + /// * `AsGateway` — token speaks for the gateway itself. + /// User identity is conveyed through separate context. + /// Cache key falls back to the gateway'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()), + 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(), + } + } + + // -------- Input builders -------- + + 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 + } + + // -------- Input read accessors -------- + + /// 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 + } + + 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() + } + + // -------- Output helpers -------- + + /// 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); + } + } + + // -------- Host-side application helpers -------- + + /// 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). + /// + /// # Open work + /// + /// The `DelegationKey` we synthesize here uses only fields the + /// payload knows about — `audience`, `scopes` (derived from the + /// effective scopes on the minted token), `mode`. The `subject_id` + /// field of `DelegationKey` requires reading the request's + /// `Extensions.security.subject.id`; we plumb that lookup here + /// rather than asking outbound callers to thread the subject + /// through. If `security.subject.id` is absent the key falls back + /// to the empty string — flagged via tracing but not fatal, + /// because some delegation flows are gateway-as-principal + /// (AsGateway mode) and don't need a subject. + 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(); + + // Default to OnBehalfOfUser when the handler didn't + // populate `delegation_mode`. Backward-compatible with + // handlers from sub-step B; future handlers should + // populate the field explicitly. + let mode = self + .delegation_mode + .clone() + .unwrap_or(DelegationMode::OnBehalfOfUser); + let key = DelegationKey { + subject_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 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(), + audience: "https://hr.example.com".into(), + scopes: vec!["read:compensation".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert!(raw.delegated_tokens.contains_key(&expected_key)); + } + + #[test] + fn apply_to_extensions_respects_explicit_delegation_mode() { + // Handler that mints an AsGateway-mode token (gateway-as-principal + // flow). The key in `delegated_tokens` should carry AsGateway, + // not the default OnBehalfOfUser. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "gateway-token", + "Authorization", + "https://downstream.example.com", + vec!["service:call".into()], + Utc::now(), + )); + p.delegation_mode = Some( + crate::extensions::raw_credentials::DelegationMode::AsGateway, + ); + + 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::AsGateway + )); + } + + #[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::AsGateway, + ); + base.merge(overlay); + assert!(matches!( + base.delegation_mode, + Some(crate::extensions::raw_credentials::DelegationMode::AsGateway) + )); + } + + #[test] + fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() { + // Gateway-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/executor.rs b/crates/cpex-core/src/executor.rs index ddf4247a..333f725e 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -689,12 +689,18 @@ impl Executor { /// Run the concurrent phase — plugins execute truly in parallel. /// Returns the first violation if any plugin denies. /// - /// Uses a `JoinSet` rather than `Vec + join_all` so we can: - /// - react to results as they complete (`join_next_with_id`) rather than - /// waiting for the slowest task before noticing a deny; - /// - cancel remaining tasks when a halt condition is hit (`abort_all`), - /// making `short_circuit_on_deny` actually short-circuit and bounding - /// the side-effects timed-out / errored handlers can produce. + /// 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], @@ -703,34 +709,48 @@ impl Executor { 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); - // Spawn into a JoinSet keyed by tokio task::Id so we can map a - // completed task (or a panicked one — JoinError carries the id) - // back to its entry without positional zip. - type ConcurrentTaskOutput = Result< - Result, Box>, - tokio::time::error::Elapsed, - >; - let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); - let mut id_to_index: std::collections::HashMap = - std::collections::HashMap::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { + // 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 dur = timeout_dur; + 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. @@ -743,117 +763,96 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let abort_handle = set.spawn(async move { - timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await - }); - id_to_index.insert(abort_handle.id(), idx); + 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 mut denials: Vec = Vec::new(); + let cfg = BranchConfig { + timeout_per_branch: Some(timeout_dur), + short_circuit_on_deny: self.config.short_circuit_on_deny, + }; - while let Some(joined) = set.join_next_with_id().await { - // Pull the task::Id and outcome out of the success/error envelope - // so we can look up the entry by id even when the task panicked. - let (task_id, outcome) = match joined { - Ok((id, result)) => (id, Ok(result)), - Err(join_err) => { - let id = join_err.id(); - (id, Err(join_err)) - } - }; - let idx = match id_to_index.get(&task_id) { - Some(i) => *i, - None => { - // Should be impossible — we registered every spawn. - error!("CONCURRENT: untracked task id {:?}", task_id); - continue; - } - }; + // `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 = entry.plugin_ref.trusted_config().on_error; + let on_error = on_error_by_idx[idx]; - let result = match outcome { - Ok(r) => r, - Err(e) => { - // Spawned task panicked. Apply the plugin's on_error - // policy just like a returned error or timeout. On - // Fail, abort the remaining tasks before halting. - error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, e); - let panic_err = crate::error::PluginError::Execution { - plugin_name: plugin_name.to_string(), - message: format!("task panicked: {}", e), - source: None, - code: Some("panic".into()), - details: std::collections::HashMap::new(), - proto_error_code: None, - }; - match on_error { - OnError::Fail => { + 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_panic", - format!("Plugin '{}' task panicked: {}", plugin_name, e), + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), ); v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return 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(); + first_violation = Some(v); } } - continue; - } - }; - - match result { - Ok(Ok(result_box)) => { - if let Some(erased) = extract_erased(result_box) { - if !erased.continue_processing { - let mut violation = erased.violation.unwrap_or_else(|| { - crate::error::PluginViolation::new( - "concurrent_deny", - format!("Plugin '{}' denied", plugin_name), - ) - }); - violation.plugin_name = Some(plugin_name.to_string()); - if self.config.short_circuit_on_deny { - // Real short-circuit: cancel the rest before - // they keep running and writing side-effects. - set.abort_all(); - return Some(violation); - } - denials.push(violation); - } - } - } - Ok(Err(e)) => match on_error { - OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_error", - format!("Plugin '{}' failed: {}", plugin_name, e), - ); - v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); - } OnError::Ignore => { warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); - errors.push((&e).into()); + errors.push((&*e).into()); } OnError::Disable => { warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); - errors.push((&e).into()); + errors.push((&*e).into()); entry.plugin_ref.disable(); } }, - Err(_) => { + BranchOutcome::TimedOut => { let timeout_err = crate::error::PluginError::Timeout { plugin_name: plugin_name.to_string(), timeout_ms: timeout_dur.as_millis() as u64, @@ -861,13 +860,14 @@ impl Executor { }; match on_error { OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_timeout", - format!("Plugin '{}' timed out", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); + 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); @@ -880,14 +880,47 @@ impl Executor { } } } + 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. + } } } - // Return first denial if any were collected (non-short-circuit mode). - // Dropping `set` here also aborts any not-yet-completed tasks; with - // join_next_with_id() above we drained completions, so this is just - // belt-and-braces in case the loop exited unexpectedly. - denials.into_iter().next() + first_violation } // ----------------------------------------------------------------------- diff --git a/crates/cpex-core/src/extensions/authorization.rs b/crates/cpex-core/src/extensions/authorization.rs new file mode 100644 index 00000000..caffbb80 --- /dev/null +++ b/crates/cpex-core/src/extensions/authorization.rs @@ -0,0 +1,81 @@ +// 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, per docs/specs/delegation-hooks-rust-spec.md §9.6). + +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/container.rs b/crates/cpex-core/src/extensions/container.rs index 6409bf43..51da6a81 100644 --- a/crates/cpex-core/src/extensions/container.rs +++ b/crates/cpex-core/src/extensions/container.rs @@ -25,6 +25,7 @@ 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::security::SecurityExtension; @@ -66,6 +67,19 @@ pub struct Extensions { #[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. The + /// out-of-process consequence — remote / WASM plugins can't see + /// raw tokens at all — is intentional and documented on + /// `RawCredentialsExtension`. + #[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>, @@ -113,6 +127,7 @@ impl Clone for Extensions { http: self.http.clone(), security: self.security.clone(), delegation: self.delegation.clone(), + raw_credentials: self.raw_credentials.clone(), mcp: self.mcp.clone(), completion: self.completion.clone(), provenance: self.provenance.clone(), @@ -158,6 +173,7 @@ impl Extensions { 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())), @@ -209,6 +225,16 @@ impl Extensions { && 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`. } /// Merge an OwnedExtensions back into this Extensions. @@ -217,6 +243,18 @@ impl Extensions { self.security = owned.security.map(Arc::new); self.delegation = owned.delegation.map(Arc::new); 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; + } } } @@ -248,6 +286,11 @@ pub struct OwnedExtensions { 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 in + /// slice 2 (IdentityResolve) and slice 3 (TokenDelegate). Until + /// then, no plugin writes through `OwnedExtensions.raw_credentials`. + pub raw_credentials: Option>, // Mutable/monotonic/guarded — owned, modifiable pub http: Option>, diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs index e5f5ef50..a8f085fa 100644 --- a/crates/cpex-core/src/extensions/delegation.rs +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -6,17 +6,43 @@ // 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 in +/// docs/specs/delegation-hooks-rust-spec.md §9.5. `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. + /// 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, + pub subject_type: Option, /// Target audience. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -26,9 +52,15 @@ pub struct DelegationHop { #[serde(default)] pub scopes_granted: Vec, - /// Timestamp of delegation (ISO 8601). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option, + /// 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")] @@ -36,7 +68,7 @@ pub struct DelegationHop { /// Delegation strategy used. #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, + pub strategy: Option, /// Whether this hop was resolved from cache. #[serde(default)] @@ -53,9 +85,9 @@ pub struct DelegationExtension { #[serde(default)] pub chain: Vec, - /// Chain depth (number of hops). + /// Chain depth (number of hops). `u32` for wire-stable width. #[serde(default)] - pub depth: usize, + pub depth: u32, /// Subject ID of the original delegator. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -78,7 +110,9 @@ impl DelegationExtension { /// Append a delegation hop (monotonic — cannot remove). pub fn append_hop(&mut self, hop: DelegationHop) { self.chain.push(hop); - self.depth = self.chain.len(); + // 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; } } @@ -122,7 +156,7 @@ mod tests { subject_id: "alice".into(), audience: Some("service-b".into()), scopes_granted: vec!["read".into(), "write".into()], - strategy: Some("token_exchange".into()), + strategy: Some(DelegationStrategy::TokenExchange), ..Default::default() }); @@ -139,6 +173,25 @@ mod tests { 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 { @@ -148,7 +201,7 @@ mod tests { }; del.append_hop(DelegationHop { subject_id: "alice".into(), - subject_type: Some("user".into()), + subject_type: Some(SubjectType::User), scopes_granted: vec!["admin".into()], from_cache: true, ..Default::default() diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 1841164a..c8d1cdd0 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -43,8 +43,15 @@ pub enum SlotName { SecuritySubjectTeams, SecuritySubjectClaims, SecuritySubjectPermissions, + SecurityClient, + SecurityCallerWorkload, + SecurityThisWorkload, SecurityObjects, SecurityData, + // Raw credentials sub-slots (Layer 3 — capability-gated, never + // visible to out-of-process plugins regardless of cap). + RawCredentialsInbound, + RawCredentialsDelegated, } /// Get the policy for a given slot. @@ -167,6 +174,44 @@ pub fn slot_policy(slot: SlotName) -> SlotPolicy { 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, + }, } } @@ -282,9 +327,59 @@ pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet 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 @@ -298,12 +393,16 @@ fn build_filtered_security( objects: security.objects.clone(), data: security.data.clone(), classification: security.classification.clone(), - // Agent identity and auth method — always included (host-set, immutable) - agent: security.agent.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 for capability-gated fields + // 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 @@ -312,13 +411,48 @@ fn build_filtered_security( filtered.labels = security.labels.clone(); } - // Subject — granular capability-gated + // 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 (previously this slot was always-visible + // under the old `agent` name; the cap gating is intentional new + // behavior, per spec §4.4). + 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 } @@ -544,4 +678,186 @@ mod tests { assert!(filtered.delegation.is_some()); assert!(filtered.delegation.unwrap().delegated); } + + // ----------------------------------------------------------------- + // New identity-slot capability gating (slice 1 step C) + // ----------------------------------------------------------------- + + /// 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()); + } + + // ----------------------------------------------------------------- + // RawCredentialsExtension capability gating + // ----------------------------------------------------------------- + + 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(), + 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/mod.rs b/crates/cpex-core/src/extensions/mod.rs index d51aec62..69a57bf3 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -12,6 +12,7 @@ // Mirrors the Python extensions in cpex/framework/extensions/. pub mod agent; +pub mod authorization; pub mod completion; pub mod container; pub mod delegation; @@ -24,6 +25,7 @@ pub mod mcp; pub mod meta; pub mod monotonic; pub mod provenance; +pub mod raw_credentials; pub mod request; pub mod security; pub mod tiers; @@ -33,8 +35,9 @@ 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}; +pub use delegation::{DelegationExtension, DelegationHop, DelegationStrategy}; pub use filter::{filter_extensions, SlotName}; pub use framework::FrameworkExtension; pub use guarded::{Guarded, WriteToken}; @@ -44,9 +47,13 @@ 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 security::{ - AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, - SubjectExtension, SubjectType, + ClientExtension, ClientTrustLevel, DataPolicy, ObjectSecurityProfile, RetentionPolicy, + SecurityExtension, SubjectExtension, SubjectType, WorkloadIdentity, }; pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/extensions/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs new file mode 100644 index 00000000..f3d175b7 --- /dev/null +++ b/crates/cpex-core/src/extensions/raw_credentials.rs @@ -0,0 +1,342 @@ +// 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 (docs/specs/delegation-hooks-rust-spec.md §4.2). +// 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. +// +// This implicitly means **out-of-process plugins (remote / WASM) +// cannot read or write raw credentials**. That's by design — the +// security audit story is much simpler when "raw credentials never +// leave the host process" is an invariant rather than a per-plugin +// trust decision. Handlers that need raw material must run in-process. +// See the slice plan and the architecture discussion in +// `docs/raw-credentials-slice-plan.md` for the reasoning. +// +// # 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 inbound workload, when SPIFFE + /// attestation is JWT-based instead of mTLS-based. + Workload, + /// 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, +} + +/// Whether a delegated outbound token represents the user's identity +/// or the gateway's own identity to the downstream service. Affects +/// scope-narrowing rules and audit-log attribution. +#[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). + OnBehalfOfUser, + /// Outbound token represents the gateway / agent itself as the + /// principal; user identity is conveyed via separate context. + AsGateway, +} + +/// 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())`. Document this +/// invariant when handing instances across any process boundary. +#[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, audience, scopes, mode)` so different audiences +/// or scope sets for the same subject mint independent tokens. +/// +/// `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. +#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)] +pub struct DelegationKey { + pub subject_id: String, + pub audience: String, + pub scopes: Vec, + pub mode: DelegationMode, +} + +/// 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 invariant 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` (spec §10.7) 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(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into(), "write".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + let k2 = DelegationKey { + subject_id: "alice".into(), + 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); + } + + #[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/security.rs b/crates/cpex-core/src/extensions/security.rs index 91d54c18..34ceac86 100644 --- a/crates/cpex-core/src/extensions/security.rs +++ b/crates/cpex-core/src/extensions/security.rs @@ -8,7 +8,9 @@ use std::collections::{HashMap, HashSet}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use super::monotonic::MonotonicSet; @@ -106,41 +108,179 @@ pub struct DataPolicy { pub retention: Option, } -/// This agent's own workload identity. +/// 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. /// -/// Distinct from `SubjectExtension` which represents the *caller*. -/// `AgentIdentity` represents *this agent/service* — its own -/// workload identity, OAuth client_id, and trust domain. -/// -/// Populated by the host before the pipeline runs. Plugins can -/// make decisions based on both who is calling (Subject) and -/// which agent is processing (AgentIdentity). +/// `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). /// -/// Maps to AuthBridge's `AgentIdentity` and the Go bindings' -/// `SecurityExtension.Agent`. +/// 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 AgentIdentity { - /// OAuth client_id of this agent. +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_id: Option, + pub client_name: Option, - /// Workload identity URI (SPIFFE, k8s service account, platform-specific). - /// e.g., `spiffe://example.com/ns/team1/sa/weather-tool` + /// 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 workload_id: Option, + pub spiffe_id: Option, - /// Trust domain of the workload identity. - /// e.g., `example.com` + /// 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, -/// authenticated caller identity (subject), this agent's own -/// workload identity (agent), object security profiles, and -/// data policies. +/// up to four distinct identity principals, and data-policy metadata. +/// The four principal slots map to the identity sources documented in +/// `docs/specs/delegation-hooks-rust-spec.md` §4.1: +/// +/// - `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). @@ -152,14 +292,31 @@ pub struct SecurityExtension { #[serde(default, skip_serializing_if = "Option::is_none")] pub classification: Option, - /// Authenticated caller identity (who is calling). + /// Authenticated *user* identity (who is calling). #[serde(default, skip_serializing_if = "Option::is_none")] pub subject: Option, - /// This agent's own workload identity (who this agent is). - /// Populated by the host, not by plugins. + /// 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 agent: Option, + 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")] @@ -229,30 +386,38 @@ mod tests { } #[test] - fn test_agent_identity() { - let agent = AgentIdentity { - client_id: Some("weather-agent".into()), - workload_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), + 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!(agent.client_id.as_deref(), Some("weather-agent")); assert_eq!( - agent.workload_id.as_deref(), + w.spiffe_id.as_deref(), Some("spiffe://example.com/ns/team1/sa/weather-tool") ); - assert_eq!(agent.trust_domain.as_deref(), Some("example.com")); + 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_agent_identity_default() { - let agent = AgentIdentity::default(); - assert!(agent.client_id.is_none()); - assert!(agent.workload_id.is_none()); - assert!(agent.trust_domain.is_none()); + 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_agent_and_subject() { + fn test_security_with_this_workload_and_subject() { let sec = SecurityExtension { labels: { let mut l = super::super::MonotonicSet::new(); @@ -265,10 +430,11 @@ mod tests { subject_type: Some(SubjectType::User), ..Default::default() }), - agent: Some(AgentIdentity { - client_id: Some("hr-agent".into()), - workload_id: Some("spiffe://corp.com/hr-agent".into()), + 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() @@ -276,13 +442,13 @@ mod tests { // Caller identity assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); - // Agent identity (distinct from caller) + // Our own workload identity (distinct from caller) assert_eq!( - sec.agent.as_ref().unwrap().client_id.as_deref(), + sec.this_workload.as_ref().unwrap().client_id.as_deref(), Some("hr-agent") ); assert_eq!( - sec.agent.as_ref().unwrap().trust_domain.as_deref(), + sec.this_workload.as_ref().unwrap().trust_domain.as_deref(), Some("corp.com") ); // Auth method @@ -296,7 +462,7 @@ mod tests { let mut sec = SecurityExtension::default(); sec.add_label("PII"); sec.classification = Some("internal".into()); - sec.agent = Some(AgentIdentity { + sec.this_workload = Some(WorkloadIdentity { client_id: Some("my-agent".into()), ..Default::default() }); @@ -308,7 +474,12 @@ mod tests { assert!(deserialized.has_label("PII")); assert_eq!(deserialized.classification.as_deref(), Some("internal")); assert_eq!( - deserialized.agent.as_ref().unwrap().client_id.as_deref(), + deserialized + .this_workload + .as_ref() + .unwrap() + .client_id + .as_deref(), Some("my-agent") ); assert_eq!(deserialized.auth_method.as_deref(), Some("mtls")); diff --git a/crates/cpex-core/src/extensions/tiers.rs b/crates/cpex-core/src/extensions/tiers.rs index a22406f9..cc3592d1 100644 --- a/crates/cpex-core/src/extensions/tiers.rs +++ b/crates/cpex-core/src/extensions/tiers.rs @@ -25,33 +25,93 @@ pub enum MutabilityTier { } /// 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. + // ----- Subject (user identity) ----- + /// 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. + /// Read subject roles (`security.subject.roles`). ReadRoles, - /// Read subject team memberships. + /// Read subject team memberships (`security.subject.teams`). ReadTeams, - /// Read subject claims (e.g., JWT claims). + /// Read subject claims (`security.subject.claims`). ReadClaims, - /// Read subject permissions. + /// Read subject permissions (`security.subject.permissions`). ReadPermissions, - /// Read the agent execution context. + + // ----- Client (OAuth application identity) ----- + /// 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, + + // ----- Workload (attested SPIFFE / mTLS identity) ----- + /// 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, + + // ----- Agent execution context (session / conversation) ----- + /// 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, + + // ----- HTTP wire layer ----- /// Read HTTP headers. ReadHeaders, /// Write (modify) HTTP headers. WriteHeaders, + + // ----- Security labels (taint flow) ----- /// Read security labels. ReadLabels, /// Append security labels (monotonic add-only). AppendLabels, + + // ----- Delegation chain (validated) ----- /// Read the delegation chain. ReadDelegation, /// Append to the delegation chain (monotonic). AppendDelegation, + + // ----- Raw credentials (Layer 3) ----- + /// 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. + /// Out-of-process plugins can't see these tokens regardless of + /// capability — token fields are `#[serde(skip)]`. + 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 out-of-process caveat as + /// `read_inbound_credentials`. + ReadDelegatedTokens, } /// Access policy for an extension slot. diff --git a/crates/cpex-core/src/hooks/metadata.rs b/crates/cpex-core/src/hooks/metadata.rs new file mode 100644 index 00000000..ff9de667 --- /dev/null +++ b/crates/cpex-core/src/hooks/metadata.rs @@ -0,0 +1,369 @@ +// 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 policy and post_policy 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. See `docs/apl-hook-family-expansion.md` Layer 1. +// +// # Phase semantics +// +// APL phases map to hook phases: +// +// * `args:` field stage → looks for `Pre` hooks +// * `policy:` step → looks for `Pre` hooks +// * `result:` field stage → looks for `Post` hooks +// * `post_policy:` step → looks for `Post` hooks +// +// A plugin that wants to discriminate "args field stage" from +// "policy 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/policy phases dispatch to `Pre` hooks; APL's +/// result/post_policy phases dispatch to `Post` hooks. Hook families +/// outside the request-lifecycle model (identity at request entry, +/// token-delegate inside policy) 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 `policy:` steps. + Pre, + /// Post-invocation hook — e.g. `cmf.tool_post_invoke`, + /// `cmf.llm_output`. Dispatched from APL's `result:` field stages + /// and `post_policy:` 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 registry +// ===================================================================== + +/// 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 index e7fb48f3..4139b670 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -18,12 +18,14 @@ 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/identity/hook.rs b/crates/cpex-core/src/identity/hook.rs new file mode 100644 index 00000000..a2a77576 --- /dev/null +++ b/crates/cpex-core/src/identity/hook.rs @@ -0,0 +1,99 @@ +// 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 in sub-step B / C of slice 2. + +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 new file mode 100644 index 00000000..28fca362 --- /dev/null +++ b/crates/cpex-core/src/identity/mod.rs @@ -0,0 +1,25 @@ +// 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 (slice 3); the two hook families share +// nothing in terms of payloads so they get separate `HookTypeDef` +// markers. +// +// Sub-step A scope: data shapes only — no executor wiring, no +// framework merge-into-Extensions logic, no APL integration. Those +// land in sub-steps B / C / D. + +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 new file mode 100644 index 00000000..ed886d5e --- /dev/null +++ b/crates/cpex-core/src/identity/payload.rs @@ -0,0 +1,460 @@ +// 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 { + // ----- Input (private — host-supplied, never mutated by handlers) ----- + /// 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, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// 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(), + } + } + + // -------- Input builders -------- + + 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 + } + + // -------- Input read accessors (no mutable variants) -------- + + /// 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 + } + + // -------- Output helpers -------- + + /// 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); + } + } + + // -------- Host-side application helpers -------- + + /// 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 new file mode 100644 index 00000000..1c9aa357 --- /dev/null +++ b/crates/cpex-core/src/identity/route_config.rs @@ -0,0 +1,202 @@ +// 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. +// +// See `docs/apl-identity-delegation-design.md` for the full design. +// +// # 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 (Slice C, 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 Slice C lands. Slice A + /// stores the flag 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 index f2f8f80c..12378bfd 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -20,16 +20,23 @@ // - [`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) // - [`error`] — Error types, violations, and result types pub mod cmf; pub mod config; pub mod context; +pub mod delegation; 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 index 16764d49..1dfb7b05 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -1,7 +1,7 @@ // Location: ./crates/cpex-core/src/manager.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor +// Authors: Teryl Taylor, Fred Araujo // // Plugin manager. // @@ -26,7 +26,7 @@ use std::hash::{Hash, Hasher}; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use hashbrown::HashMap; @@ -170,6 +170,46 @@ struct RuntimeSnapshot { /// 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 { @@ -204,6 +244,15 @@ pub struct PluginManager { /// 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 @@ -213,6 +262,13 @@ pub struct PluginManager { /// /// `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 @@ -300,6 +356,7 @@ fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> Ru executor, cpex_config: Some(cpex_config), route_cache_max_entries, + route_annotations: HashMap::new(), } } @@ -312,6 +369,7 @@ impl PluginManager { 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), @@ -320,7 +378,9 @@ impl PluginManager { 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()), } } @@ -341,6 +401,10 @@ impl PluginManager { 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 } @@ -355,9 +419,23 @@ impl PluginManager { 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) + } + // ----------------------------------------------------------------------- // Factory Registration // ----------------------------------------------------------------------- @@ -435,6 +513,10 @@ impl PluginManager { 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(); @@ -442,6 +524,154 @@ impl PluginManager { 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 cpex_config: CpexConfig = serde_yaml::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!("CpexConfig deserialize error: {}", e), + }) + })?; + + // 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(); + let policies_yaml = global_yaml + .get("policies") + .and_then(serde_yaml::Value::as_mapping) + .cloned(); + 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. @@ -713,7 +943,11 @@ impl PluginManager { let hook_type = HookType::new(hook_name); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // 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, @@ -791,7 +1025,10 @@ impl PluginManager { let hook_type = HookType::new(H::NAME); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // 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()), @@ -862,7 +1099,13 @@ impl PluginManager { let hook_type = HookType::new(hook_name); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // 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()), @@ -895,6 +1138,151 @@ impl PluginManager { .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 + } + + // ----------------------------------------------------------------------- + // Route Annotation + // ----------------------------------------------------------------------- + + /// 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); + }); + } + // ----------------------------------------------------------------------- // Route Filtering // ----------------------------------------------------------------------- @@ -916,6 +1304,45 @@ impl PluginManager { 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 @@ -976,14 +1403,29 @@ impl PluginManager { } } - // Slow path: resolve, filter, and cache (allocations only here) - let resolved = config::resolve_plugins_for_entity( - cpex_config, - entity_type, - entity_name, - request_scope, - &meta.tags, - ); + // 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, @@ -1045,6 +1487,173 @@ impl PluginManager { 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 @@ -1184,11 +1793,25 @@ impl PluginManager { // Query Methods // ----------------------------------------------------------------------- - /// Whether any plugins are registered for the given hook name. + /// 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 { - self.load_runtime() + 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 diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index 0b4990c1..30f68bf3 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -459,6 +459,24 @@ impl PluginRegistry { 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 { diff --git a/crates/cpex-core/src/visitor.rs b/crates/cpex-core/src/visitor.rs new file mode 100644 index 00000000..98651cfd --- /dev/null +++ b/crates/cpex-core/src/visitor.rs @@ -0,0 +1,134 @@ +// 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 new file mode 100644 index 00000000..10ff13b9 --- /dev/null +++ b/crates/cpex-core/tests/delegation_e2e.rs @@ -0,0 +1,722 @@ +// 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 — sub-step B of +// slice 3. +// +// 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, audience, scopes, mode). + let expected_key = DelegationKey { + subject_id: "alice@corp.com".into(), + audience: "https://hr.example.com".into(), + // Order matches what StubExchanger produces (required_permissions + // first, then attenuation capabilities). + scopes: vec!["read:compensation".into(), "audit".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + 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 slice 2 `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 new file mode 100644 index 00000000..d262a1b2 --- /dev/null +++ b/crates/cpex-core/tests/identity_e2e.rs @@ -0,0 +1,744 @@ +// 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 — sub-step B +// of slice 2. +// +// 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::SecurityExtension; + use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, + }; + + // ----- 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. Note: this only works + // because RawCredentialsExtension lives in-process — out-of-process + // serialization would strip the token field. + 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 new file mode 100644 index 00000000..05ae3b19 --- /dev/null +++ b/crates/cpex-core/tests/identity_route_e2e.rs @@ -0,0 +1,867 @@ +// 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 `identity:` block (Slice A). +// +// Verifies the hook-specific binding semantics: +// * A route's `identity:` 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 +// `identity:`, 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 `identity:` 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 `identity:` 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 + identity: + - 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 `identity:` declaration, NOT plugin priority. + let firings = ledger.lock().unwrap().clone(); + assert_eq!(firings, vec!["jwt-c", "jwt-a", "jwt-b"]); +} + +/// `identity:` 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 `identity:` 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 `identity:` 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 `identity:`. + 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 + identity: + - 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 + // `identity:`, so it's NOT bound for this hook on this route. + assert_eq!(ledger.lock().unwrap().clone(), vec!["corp-jwt"]); +} + +/// A route with no `identity:` 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 — `identity:` 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 + identity: + - 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 + identity: + - 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", + ); +} + +/// Slice C — 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: + identity: + - 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: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml + +routes: + - tool: get_compensation + meta: + tags: [finance] + identity: + - 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: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml + +routes: + - tool: legacy_endpoint + meta: + tags: [finance] + identity: + 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: + identity: + - corp-jwt + +routes: + - tool: public_endpoint + identity: + 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 + identity: + 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 (Slice A). +// +// 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 + identity: + - 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 + identity: + - 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 73d55683..8adcb8ce 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -20,6 +20,19 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] cpex-core = { path = "../cpex-core" } +# APL governance layer — bundled so Go/Python hosts can enable APL +# policies, route handlers, and the standard plugin/PDP factories via +# the `cpex_apl_install` FFI entry point. Symbols survive in the +# staticlib because that entry point references each factory. +apl-cpex = { path = "../apl-cpex" } +apl-pii-scanner = { path = "../apl-pii-scanner" } +apl-audit-logger = { path = "../apl-audit-logger" } +apl-identity-jwt = { path = "../apl-identity-jwt" } +apl-delegator-oauth = { path = "../apl-delegator-oauth" } +apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" } +# Heavy (~200 transitive deps via the Cedarling git dep); kept out of the +# default `.a` and behind the `cedarling` feature. +apl-cedarling = { path = "../apl-cedarling", optional = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -27,5 +40,11 @@ rmp-serde = { workspace = true } serde_bytes = { workspace = true } tracing = { workspace = true } +[features] +default = [] +# Opt-in Cedarling-backed identity + PDP. Build with +# `cargo build -p cpex-ffi --features cedarling`. +cedarling = ["dep:apl-cedarling"] + [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md new file mode 100644 index 00000000..0430a34b --- /dev/null +++ b/crates/cpex-ffi/RELEASE.md @@ -0,0 +1,231 @@ +# `libcpex_ffi.a` — Release Artifacts + +CPEX publishes pre-built `libcpex_ffi.a` static libraries as signed +GitHub Release artifacts. Downstream consumers (Go bindings, +language bindings, anyone embedding CPEX) link against these +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`. The +> Cedarling-backed seams are **not** in the default `.a` — build with +> `cargo build -p cpex-ffi --features cedarling` to include them. + +## What is published + +Every CPEX release tagged `vMAJOR.MINOR.PATCH` (or +`vMAJOR.MINOR.PATCH-`) attaches one tarball per +supported target tuple to the GitHub Release, along with checksums +and signatures. + +### Naming and layout + +For release `vX.Y.Z` and tuple `-[-]`: + +``` +cpex-ffi-vX.Y.Z--[-].tar.gz +cpex-ffi-vX.Y.Z--[-].tar.gz.sha256 +cpex-ffi-vX.Y.Z--[-].tar.gz.sig +cpex-ffi-vX.Y.Z--[-].tar.gz.crt +``` + +Plus one aggregate integrity manifest for the whole release: + +``` +cpex-ffi-vX.Y.Z-SHA256SUMS +cpex-ffi-vX.Y.Z-SHA256SUMS.sig +cpex-ffi-vX.Y.Z-SHA256SUMS.crt +``` + +Each tarball, when extracted, contains: + +| File | Contents | +|-------------------|---------------------------------------------------| +| `libcpex_ffi.a` | Static library — the actual deliverable. | +| `VERSION` | Plain text. Keys: `version`, `git_sha`, `build_date`, `tuple`, `rust_target`. | +| `FFI_ABI` | Single integer line — FFI ABI version. See policy below. | +| `LICENSE` | Copy of CPEX's Apache-2.0 license. | + +Tarballs are flat (no leading directory). `tar xzf -C ` drops the four files directly into ``. + +### Target matrix + +| Tuple | Rust target triple | Runner | +|----------------------|---------------------------------|-----------------| +| `linux-amd64-gnu` | `x86_64-unknown-linux-gnu` | `ubuntu-latest` | +| `linux-arm64-gnu` | `aarch64-unknown-linux-gnu` | `ubuntu-22.04-arm` | +| `linux-amd64-musl` | `x86_64-unknown-linux-musl` | `ubuntu-latest` | +| `linux-arm64-musl` | `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` | +| `darwin-arm64` | `aarch64-apple-darwin` | `macos-14` | + +`darwin-amd64` and Windows targets are not built in v1. Open an +issue if you need one — adding to the matrix is mechanical. + +### Signing + +Tarballs and the aggregate `SHA256SUMS` are signed with +[cosign](https://github.com/sigstore/cosign) **keyless** via +Sigstore (Fulcio for cert issuance, Rekor for transparency). There +is no long-lived signing key — each release produces short-lived +certs bound to the GitHub Actions OIDC identity of the +`release-ffi.yaml` workflow on the canonical repo. Verification +checks both the cert subject and the OIDC issuer. + +## How to consume + +### One-shot: the helper script + +The repo ships `scripts/download-ffi-artifact.sh` — vendor it +into your build (or fetch via `raw.githubusercontent.com` pinned to +a tag) and call it before `go build` / `cargo build` / etc. + +```sh +export CPEX_FFI_VERSION=v0.9.0 +ARTIFACT_DIR=$(bash scripts/download-ffi-artifact.sh) +export CGO_LDFLAGS="-L${ARTIFACT_DIR} -lcpex_ffi" +go build ./... +``` + +What it does: + +1. Auto-detects your tuple from `uname -s` / `uname -m` (override + with `CPEX_FFI_TARGET`). +2. Downloads the tarball, `.sha256`, `.sig`, `.crt`. +3. Verifies the SHA256 — non-skippable. +4. Verifies the cosign signature against the canonical workflow + identity and OIDC issuer — skippable via + `CPEX_FFI_SKIP_COSIGN=1` only for air-gapped environments. +5. Unpacks to `${CPEX_FFI_DEST}` (default + `./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/`). +6. Prints the absolute destination to stdout. + +Subsequent runs against the same version + dest are no-ops. + +### Manual: cosign + tar + +If you want to do it by hand: + +```sh +VER=v0.9.0 +TUPLE=linux-amd64-gnu +BASE="https://github.com/contextforge-org/cpex/releases/download/${VER}" +NAME="cpex-ffi-${VER}-${TUPLE}.tar.gz" + +curl -fsSLO "${BASE}/${NAME}" +curl -fsSLO "${BASE}/${NAME}.sha256" +curl -fsSLO "${BASE}/${NAME}.sig" +curl -fsSLO "${BASE}/${NAME}.crt" + +sha256sum -c "${NAME}.sha256" + +cosign verify-blob \ + --certificate "${NAME}.crt" \ + --signature "${NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/contextforge-org/cpex/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${NAME}" + +mkdir -p ./libcpex +tar xzf "${NAME}" -C ./libcpex +``` + +After this, `./libcpex/libcpex_ffi.a` is your link target. + +### Using with the in-tree Go binding + +`go/cpex/ffi.go` links via `#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi` +relative to the cpex repo layout. For downstream Go consumers that +pull `go/cpex` via `go get`, set `CGO_LDFLAGS` to point at the +unpacked artifact directory and the cgo `-L` from `LDFLAGS` will be +augmented by the env var: + +```sh +ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.9.0 bash scripts/download-ffi-artifact.sh) +CGO_LDFLAGS="-L${ARTIFACT_DIR}" go build ./... +``` + +## FFI ABI policy + +The `FFI_ABI` integer in each bundle declares the wire-level C +contract version that `libcpex_ffi.a` exposes. Language bindings +must record the ABI version they were generated against and check +it at runtime — the Go binding does this in `go/cpex/abi.go`'s +`init()` and panics on mismatch. Every other binding **must do the +same**; silent acceptance of an ABI mismatch produces undefined +behavior on every subsequent FFI call. + +### What counts as an ABI break + +A bump of `FFI_ABI_VERSION` is **required** for any of: + +- Adding, removing, or renaming an `extern "C"` function. +- Changing argument count, argument type, or return type of an + existing extern function. +- Changing the layout of a struct that crosses the boundary. +- Changing the ownership or lifetime contract of a pointer + returned from / accepted by an extern function. +- Changing the semantics of a return code for a previously-success + case (e.g. a function that used to return `RC_OK` now returns a + new code on the same input). + +A bump is **not** required for: + +- Adding a new `RC_*` code at the end of the existing range. + Existing wire codes are stable; consumers should treat unknown + codes as generic failure. +- Internal Rust refactors that leave the C surface unchanged. +- Documentation / comment changes. + +### Process + +1. The Rust author bumps `FFI_ABI_VERSION` in + `crates/cpex-ffi/src/lib.rs` in the same PR as the breaking + change. +2. All in-tree language bindings (today: `go/cpex/abi.go`'s + `expectedFFIABIVersion`) are bumped to match in the same PR. +3. `CHANGELOG.md` records the bump under **Changed** with the + from→to integers and a one-line description of what moved. +4. The release tag that ships the breaking change is a new + `MINOR` (or `MAJOR`) — never a `PATCH`. + +## Versioning + +The artifact tag matches the CPEX repo tag exactly. There is no +separate "FFI version" — `vX.Y.Z` of CPEX produces `cpex-ffi-vX.Y.Z-*` +artifacts. Prereleases (`vX.Y.Z-rc1`, `vX.Y.Z-beta.1`, +`vX.Y.Z-ffi.test.1`, etc.) publish too and land as GitHub Releases +flagged "prerelease" — they don't surface as "latest". + +The FFI ABI version is independent: a release that doesn't touch +the C surface keeps the same `FFI_ABI`, even across minor / major +CPEX bumps. + +## Reproducibility caveats + +Builds use `cargo build --release --locked`, which pins the +`Cargo.lock` resolution. Beyond that, no guarantees: + +- Timestamps in the built `.a` differ between runs. +- Compiler / OS image patch versions on the runner can shift. +- macOS code-signing metadata varies per build. + +Consumers care about `FFI_ABI` (contract stability) and SHA + cosign +(integrity + authenticity), not bit-identical reproducibility. +Adding `cargo-zigbuild` or a sysroot-pinning toolchain to harden +reproducibility is a v2 ask. + +## When something is wrong + +| Symptom | Likely cause / fix | +|----------------------------------|----------------------------------------------------------------------------| +| `cosign verify-blob` fails | Wrong `--certificate-identity-regexp` (must point at the canonical repo's `release-ffi.yaml`), or the artifact came from a fork rather than the canonical workflow. | +| sha256 mismatch | The download was corrupted or the upstream release was rewritten. Open an issue. | +| Go `init` panics with ABI mismatch | The linked `.a` and the Go binding were generated against different ABI versions. Pin both to the same CPEX tag. | +| Unsupported tuple | Your platform isn't in the matrix. Either add it (PR welcome) or build the `.a` locally from source. | +| `tar` complains about absolute paths | Bundles are flat (no leading dir). Extract with `tar xzf -C `, not into the current dir. | diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs new file mode 100644 index 00000000..d9300469 --- /dev/null +++ b/crates/cpex-ffi/src/apl.rs @@ -0,0 +1,101 @@ +// Location: ./crates/cpex-ffi/src/apl.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// APL (Attribute Policy Language) FFI wiring. +// +// `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. +// +// Registration is explicit (no inventory/ctor magic): each factory is +// referenced here so its object code survives in `libcpex_ffi.a`. Adding +// a new bundled factory means adding a `register_factory` call below. +// +// Ordering: call AFTER `cpex_manager_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: +// +// cpex_manager_new_default +// → cpex_apl_install +// → cpex_load_config +// → cpex_initialize + +use std::os::raw::c_int; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Arc; + +use crate::{CpexManagerInner, RC_INVALID_HANDLE, 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`): +/// - `validator/pii-scan` → apl-pii-scanner +/// - `audit/logger` → apl-audit-logger +/// - `identity/jwt` → apl-identity-jwt +/// - `delegator/oauth` → apl-delegator-oauth +/// +/// Bundled PDP factory (consulted for `global.apl.pdp[]` entries): +/// - `cedar-direct` → apl-pdp-cedar-direct +/// +/// With the `cedarling` cargo feature, the Cedarling-backed identity and +/// PDP seams are additionally wired. +/// +/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or +/// `RC_PANIC` if registration 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, + None => return RC_INVALID_HANDLE, + }; + + 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. + inner.manager.register_factory( + apl_pii_scanner::KIND, + Box::new(apl_pii_scanner::PiiScannerFactory), + ); + inner.manager.register_factory( + apl_audit_logger::KIND, + Box::new(apl_audit_logger::AuditLoggerFactory), + ); + inner.manager.register_factory( + apl_identity_jwt::KIND, + Box::new(apl_identity_jwt::JwtIdentityFactory), + ); + inner.manager.register_factory( + apl_delegator_oauth::KIND, + Box::new(apl_delegator_oauth::OAuthDelegatorFactory), + ); + + // APL config visitor + PDP factories. `pdp_factories` are consulted + // for `global.apl.pdp[]` entries; cedar-direct is the bundled + // default. The visitor keeps a Weak (see + // CpexManagerInner) that upgrades during load_config_yaml. + let mut opts = apl_cpex::AplOptions::in_process(); + opts.pdp_factories = + vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; + + apl_cpex::register_apl(&inner.manager, opts); + })); + + match result { + Ok(()) => RC_OK, + Err(_panic) => { + 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 760f62d9..15208453 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -1,7 +1,7 @@ // Location: ./crates/cpex-ffi/src/lib.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor +// Authors: Teryl Taylor, Fred Araujo // // CPEX FFI — C API for embedding the CPEX runtime. // @@ -15,15 +15,19 @@ use std::os::raw::{c_char, c_int}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use std::sync::OnceLock; +use std::sync::{Arc, 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; +// APL governance wiring — the `cpex_apl_install` extern "C" entry point. +mod apl; + // --------------------------------------------------------------------------- // FFI Result Codes // --------------------------------------------------------------------------- @@ -59,6 +63,50 @@ pub const RC_TIMEOUT: c_int = -6; /// Plugin panicked; caught by `catch_unwind` at the FFI boundary. pub const RC_PANIC: c_int = -7; +// --------------------------------------------------------------------------- +// FFI ABI Version +// --------------------------------------------------------------------------- +// +// The FFI ABI version is an integer that identifies the C-surface +// contract this crate exposes. Bump it on any breaking change to the +// C surface: +// +// - added / removed / renamed extern "C" function +// - argument count, argument type, or return type change on an +// existing function +// - layout change of a struct that crosses the boundary +// - semantic change to an existing function (e.g. new RC_* value +// returned for a previously-success case, change in pointer +// ownership) +// +// Adding a new RC_* code at the end of the existing range is *not* a +// breaking change (the wire codes are stable; consumers handle unknown +// codes as generic failure). +// +// Consumers — every language binding — MUST call `cpex_ffi_abi_version` +// at init and compare against the version their binding was generated +// for. Mismatch is a hard error: the C surface they generated against +// is not the one they're linked against. Document the binding's +// expected ABI version in its source. +// +// Bumps are recorded in CHANGELOG.md under "Changed" with the from→to +// integers and a one-line description of what moved. + +/// 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; + +/// Returns the FFI ABI version this `libcpex_ffi` was built with. +/// Language bindings call this at `init` and panic on mismatch +/// against the version they were generated for. +/// +/// Pure const access — no allocation, no runtime, no panics. Safe to +/// call from anywhere including signal handlers. +#[no_mangle] +pub extern "C" fn cpex_ffi_abi_version() -> u32 { + FFI_ABI_VERSION +} + /// Outer wall-clock timeout for any FFI-driven async call. Per-plugin /// `tokio::time::timeout` only catches cooperative-async timeouts; this /// catches CPU-bound or thread-blocking plugins that never yield. Set @@ -274,6 +322,14 @@ where /// Payload type IDs — must match Go constants. pub const PAYLOAD_GENERIC: u8 = 0; 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 +/// 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. +pub const PAYLOAD_IDENTITY: u8 = 2; /// Deserialize a MessagePack payload based on its type ID. /// Array-indexed — O(1) lookup, zero allocation. @@ -289,6 +345,11 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result { + let idp: IdentityPayload = rmp_serde::from_slice(bytes) + .map_err(|e| format!("identity payload deserialize failed: {}", e))?; + Ok(Box::new(idp)) + } _ => Err(format!("unknown payload type: {}", payload_type)), } } @@ -320,6 +381,13 @@ 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`. + if let Some(idp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(idp) + .map(|b| (PAYLOAD_IDENTITY, b)) + .map_err(|e| format!("identity payload serialize failed: {e}")); + } Err("unknown payload type, cannot serialize across FFI".to_string()) } @@ -332,7 +400,11 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin /// All managers share the process-singleton runtime returned by /// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. pub struct CpexManagerInner { - pub manager: PluginManager, + /// 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, } /// Opaque handle to a ContextTable (Rust-owned, not serialized). @@ -431,9 +503,12 @@ pub unsafe extern "C" fn cpex_manager_new( // silently no-op. let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); - // Load config — factories must be registered separately via cpex_register_factory + // 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); return ptr::null_mut(); @@ -448,7 +523,7 @@ pub unsafe extern "C" fn cpex_manager_new( #[no_mangle] pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -479,17 +554,22 @@ pub unsafe extern "C" fn cpex_load_config( None => return RC_INVALID_INPUT, }; - let cpex_config = match cpex_core::config::parse_config(yaml) { - Ok(c) => c, - Err(e) => { - tracing::error!("cpex_load_config: config parse failed: {}", e); - return RC_PARSE_ERROR; - } - }; + // 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 + // (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) { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } - // load_config is sync (no .await), but we still wrap in catch_unwind - // so a panic in serde / config validation doesn't unwind across FFI. - let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_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))); match load_result { Ok(Ok(())) => RC_OK, Ok(Err(e)) => { @@ -647,7 +727,29 @@ pub unsafe extern "C" fn cpex_plugin_names( /// Returns MessagePack-encoded PipelineResult + opaque handles for /// context table and background tasks. /// -/// Returns 0 on success, -1 on failure. +/// # Ownership contract +/// +/// **The caller's input `context_table` is unconditionally consumed +/// by this function** — even on error paths (RC_INVALID_HANDLE, +/// RC_INVALID_INPUT, RC_PARSE_ERROR, RC_TIMEOUT, RC_PANIC, etc.). +/// The Box is freed inside `cpex_invoke`; the caller's pointer is +/// dead once this function returns. This mirrors the pattern used +/// by `cpex_wait_background` and lets the Go binding nil its handle +/// unconditionally after the call without leaking the underlying Box. +/// +/// On `RC_OK`, a **fresh** `CpexContextTableInner` Box is allocated +/// and its raw pointer is written to `*context_table_out`. On any +/// non-OK return, `*context_table_out` is left as a null pointer +/// (initialized at function entry). The other out parameters +/// (`result_msgpack_out`, `result_len_out`, `bg_handle_out`) follow +/// the same discipline: null/zero on error, populated on success. +/// +/// Pre-P0-1 the function consumed the input only after validation +/// passed but before `run_safely`. On `RC_TIMEOUT` / `RC_PANIC` the +/// input had been consumed but `*context_table_out` was never written, +/// so the Go wrapper kept its stale handle and a subsequent +/// `ContextTable.Close()` ran `cpex_release_context_table` on +/// already-freed memory. /// /// # Safety /// All pointer parameters must be valid or NULL where documented. @@ -672,7 +774,43 @@ pub unsafe extern "C" fn cpex_invoke( context_table_out: *mut *mut CpexContextTableInner, bg_handle_out: *mut *mut CpexBackgroundTasksInner, ) -> c_int { - // Validate manager handle + // Initialize all out params to safe defaults. Any early return + // from here on leaves a consistent state for the caller: every + // 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(); + + // Take ownership of the input context_table *immediately*, before + // any validation that could return an error code. From this point + // on, the caller's `context_table` pointer is dead — equivalent + // to free'd memory from the caller's perspective. This mirrors + // how `cpex_wait_background` handles `bg_handle`: ownership + // transfers on entry, the caller nils its reference, and Rust + // is responsible for the Box's lifetime from then on. Pre-fix, + // consumption happened mid-function after some validations, which + // meant validation errors left the input alive (one ownership + // model) and post-validation errors left it consumed without + // writing `*context_table_out` (a *different* ownership model). + // Two contracts in one function is exactly what produced the + // P0-1 UAF. + let input_ctx_table: Option = if context_table.is_null() { + None + } else { + // Box::from_raw consumes the allocation; it'll drop at the + // end of this scope if not moved into Some(...). When moved + // 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); + Some(ct.table) + }; + + // Validate manager handle. `input_ctx_table` already owns the + // input data — if we return here, it drops cleanly. let inner = match mgr.as_ref() { Some(m) => m, None => return RC_INVALID_HANDLE, @@ -715,22 +853,17 @@ pub unsafe extern "C" fn cpex_invoke( Extensions::default() }; - // Get or create context table - let ctx_table: Option = if context_table.is_null() { - None - } else { - let ct = Box::from_raw(context_table); - Some(ct.table) - }; - // Invoke the hook with wall-clock timeout + panic catch. let (mut result, bg) = match run_safely( inner .manager - .invoke_by_name(name, payload, extensions, ctx_table), + .invoke_by_name(name, payload, extensions, input_ctx_table), "cpex_invoke", ) { SafeRun::Ok(r) => r, + // *context_table_out is already null (set at function entry); + // the input table has been consumed by invoke_by_name's call + // frame and dropped. Caller's handle is dead, no replacement. other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged }; @@ -801,6 +934,257 @@ pub unsafe extern "C" fn cpex_invoke( RC_OK } +/// Fused `identity.resolve` + hook invoke. +/// +/// Runs `identity.resolve` and the named hook in ONE FFI call so the +/// resolved `Extensions` — including `raw_credentials`, whose inbound +/// tokens are `#[serde(skip)]` + `Zeroizing` and therefore cannot survive +/// an FFI round-trip — flow from identity into the hook entirely in Rust +/// memory. This is the FFI analogue of an in-process host calling +/// `IdentityPayload::apply_to_extensions(...)` between hooks; it lets +/// out-of-process hosts (Go / Python / WASM) drive `delegate()` flows +/// that need the inbound bearer token, which a two-call +/// resolve-then-invoke sequence loses on the way back out. +/// +/// `identity_msgpack` is a `PAYLOAD_IDENTITY`-shaped `IdentityPayload` +/// carrying request headers. When `identity_len <= 0` or no +/// `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. +/// +/// # 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] +pub unsafe extern "C" fn cpex_invoke_resolved( + mgr: *const CpexManagerInner, + identity_msgpack: *const u8, + identity_len: c_int, + hook_name: *const c_char, + hook_len: c_int, + payload_type: u8, + payload_msgpack: *const u8, + payload_len: c_int, + extensions_msgpack: *const u8, + extensions_len: c_int, + context_table: *mut CpexContextTableInner, // NULL for first call + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + 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(); + + // 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) + }; + + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + let payload: Box = match deserialize_payload(payload_type, payload_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!("cpex_invoke_resolved: {}", e); + return RC_PARSE_ERROR; + } + }; + + let base_extensions: Extensions = if extensions_len > 0 { + let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + match rmp_serde::from_slice(ext_bytes) { + Ok(e) => e, + Err(e) => { + tracing::error!("cpex_invoke_resolved: extensions deserialize failed: {}", e); + return RC_PARSE_ERROR; + } + } + } else { + Extensions::default() + }; + + // ---- Identity resolution (in-process) ---- + // Resolve identity and merge the result into the extensions BEFORE the + // hook runs, so raw_credentials (skip-serialized across FFI) reach the + // hook in Rust memory. Skipped when no identity payload was supplied or + // no identity.resolve hook is registered. + let merged_extensions: Extensions = if identity_len > 0 + && inner + .manager + .has_hooks_for(cpex_core::identity::HOOK_IDENTITY_RESOLVE) + { + let id_bytes = match c_bytes_to_slice(identity_msgpack, identity_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + let id_payload: IdentityPayload = match rmp_serde::from_slice(id_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!( + "cpex_invoke_resolved: identity payload deserialize failed: {}", + e + ); + return RC_PARSE_ERROR; + } + }; + + let (id_result, _id_bg) = match run_safely( + inner.manager.invoke_by_name( + cpex_core::identity::HOOK_IDENTITY_RESOLVE, + Box::new(id_payload), + Extensions::default(), + None, + ), + "cpex_invoke_resolved/identity", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), + }; + + // Identity denied (bad / missing credential): surface that denial + // as the result; the hook never runs. Identity resolvers (jwt, + // 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, + ); + } + + match IdentityPayload::from_pipeline_result(&id_result) { + Some(resolved) => resolved.apply_to_extensions(base_extensions), + None => base_extensions, + } + } else { + base_extensions + }; + + // ---- Hook invoke with the identity-enriched extensions ---- + let (result, bg) = match run_safely( + inner + .manager + .invoke_by_name(name, payload, merged_extensions, input_ctx_table), + "cpex_invoke_resolved", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), + }; + + 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 +/// out-params. Shared tail for [`cpex_invoke_resolved`]'s hook-result and +/// identity-denial paths; mirrors [`cpex_invoke`]'s inline serialization. +/// `bg` is `None` on the identity-denial path (no hook background tasks to +/// hand back) — `*bg_handle_out` is then left null. +/// +/// # Safety +/// 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, + bg: Option, + payload_type: u8, + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + context_table_out: *mut *mut CpexContextTableInner, + bg_handle_out: *mut *mut CpexBackgroundTasksInner, +) -> c_int { + let (result_payload_type, modified_payload_bytes) = match result.modified_payload.as_ref() { + None => (payload_type, None), + Some(p) => match serialize_payload(p.as_ref()) { + 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, + }); + (payload_type, None) + } + }, + }; + + let modified_extensions_bytes: Option> = result + .modified_extensions + .as_ref() + .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + + let ffi_result = FfiPipelineResult { + continue_processing: result.continue_processing, + violation: result.violation, + errors: result.errors, + metadata: result.metadata, + payload_type: result_payload_type, + modified_payload: modified_payload_bytes, + modified_extensions: modified_extensions_bytes, + }; + + let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { + Ok(b) => b, + Err(e) => { + tracing::error!("cpex_invoke_resolved: result serialize failed: {}", e); + return RC_SERIALIZE_ERROR; + } + }; + + let (ptr, len) = alloc_bytes(&result_bytes); + *result_msgpack_out = ptr; + *result_len_out = len; + + *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(), + }; + + RC_OK +} + // --------------------------------------------------------------------------- // Background Tasks // --------------------------------------------------------------------------- @@ -1049,7 +1433,7 @@ mod tests { // Touch the shared runtime so it's initialized; tests use it // rather than a per-manager runtime. let _ = shared_runtime(); - let manager = cpex_core::manager::PluginManager::default(); + let manager = Arc::new(cpex_core::manager::PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -1310,4 +1694,53 @@ mod tests { assert_eq!(cpex_is_initialized(ptr::null()), 0); } } + + #[test] + fn cpex_apl_install_rejects_null_handle() { + unsafe { + assert_eq!(crate::apl::cpex_apl_install(ptr::null()), 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#" +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +"#; + unsafe { + let mgr = build_test_manager(); + + assert_eq!(crate::apl::cpex_apl_install(mgr), 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"); + + assert_eq!(cpex_initialize(mgr), 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_has_hooks_for(mgr, hook.as_ptr() as *const c_char, hook.len() as c_int), + 1, + ); + + cpex_shutdown(mgr); + } + } } diff --git a/crates/cpex-orchestration/Cargo.toml b/crates/cpex-orchestration/Cargo.toml new file mode 100644 index 00000000..1f221e2d --- /dev/null +++ b/crates/cpex-orchestration/Cargo.toml @@ -0,0 +1,34 @@ +# 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 + +[lib] + +[dependencies] +tokio = { workspace = true, features = ["rt", "time", "macros"] } +futures = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/cpex-orchestration/src/lib.rs b/crates/cpex-orchestration/src/lib.rs new file mode 100644 index 00000000..ff884fb4 --- /dev/null +++ b/crates/cpex-orchestration/src/lib.rs @@ -0,0 +1,449 @@ +// 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; + +// ===================================================================== +// Public API +// ===================================================================== + +/// 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>; + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + 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/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index a033576f..dd85aa49 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -265,7 +265,7 @@ impl PluginFactory for HeaderInjectorFactory { } /// Register CMF demo plugin factories on a manager. -pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { +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", diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index f27125c9..4cbbf53c 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -268,7 +268,7 @@ impl PluginFactory for AuditLoggerFactory { } /// Register all demo plugin factories on a manager. -pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) { +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)); diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs index 8f756f3a..8d3eb59f 100644 --- a/examples/go-demo/ffi/src/lib.rs +++ b/examples/go-demo/ffi/src/lib.rs @@ -45,12 +45,14 @@ use std::os::raw::c_int; pub unsafe extern "C" fn cpex_demo_register_factories( mgr: *mut cpex_ffi::CpexManagerInner, ) -> c_int { - let inner = match mgr.as_mut() { + let inner = match mgr.as_ref() { Some(m) => m, None => return -1, }; - demo_plugins::register_demo_factories(&mut inner.manager); - cmf_plugins::register_cmf_factories(&mut inner.manager); + // `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); 0 } diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod index 4e5bff08..cf2642dc 100644 --- a/examples/go-demo/go.mod +++ b/examples/go-demo/go.mod @@ -1,12 +1,12 @@ -module github.com/contextforge-org/contextforge-plugins-framework/examples/go-demo +module github.com/contextforge-org/cpex/examples/go-demo go 1.25.4 -require github.com/contextforge-org/contextforge-plugins-framework/go/cpex v0.0.0 +require github.com/contextforge-org/cpex/go/cpex v0.0.0 require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect ) -replace github.com/contextforge-org/contextforge-plugins-framework/go/cpex => ../../go/cpex +replace github.com/contextforge-org/cpex/go/cpex => ../../go/cpex diff --git a/examples/go-demo/main.go b/examples/go-demo/main.go index 33aecc16..c93df597 100644 --- a/examples/go-demo/main.go +++ b/examples/go-demo/main.go @@ -35,7 +35,7 @@ import ( "os" "unsafe" - cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" + cpex "github.com/contextforge-org/cpex/go/cpex" ) func main() { diff --git a/go/cpex/README.md b/go/cpex/README.md index 220eef68..20486240 100644 --- a/go/cpex/README.md +++ b/go/cpex/README.md @@ -27,7 +27,7 @@ go/cpex/ ## Quick Start ```go -import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +import cpex "github.com/contextforge-org/cpex/go/cpex" // 1. Create a manager mgr, err := cpex.NewPluginManagerDefault() diff --git a/go/cpex/abi.go b/go/cpex/abi.go new file mode 100644 index 00000000..0a35af01 --- /dev/null +++ b/go/cpex/abi.go @@ -0,0 +1,50 @@ +// Location: ./go/cpex/abi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// FFI ABI version check. +// +// 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. +// +// Bumping expectedFFIABIVersion is required (and only required) when +// the Rust crate bumps FFI_ABI_VERSION. See crates/cpex-ffi/src/lib.rs +// "FFI ABI Version" section for what counts as a breaking change. + +package cpex + +/* +#include + +// Duplicated from ffi.go / manager.go preambles — see the note in +// manager.go about cgo not merging declarations across files. +extern uint32_t cpex_ffi_abi_version(void); +*/ +import "C" + +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 + +func init() { + actual := uint32(C.cpex_ffi_abi_version()) + if actual != expectedFFIABIVersion { + 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, + )) + } +} diff --git a/go/cpex/apl.go b/go/cpex/apl.go new file mode 100644 index 00000000..c144ee65 --- /dev/null +++ b/go/cpex/apl.go @@ -0,0 +1,61 @@ +// Location: ./go/cpex/apl.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// APL (Attribute Policy Language) 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 +// LoadConfig so that LoadConfig walks the config's `apl:` blocks and +// installs per-route handlers. + +package cpex + +import ( + "fmt" +) + +/* +#include + +// Opaque handle — same typedef as manager.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 +// changes. +typedef void* CpexManager; + +extern int cpex_apl_install(CpexManager mgr); +*/ +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). +// +// Bundled plugin kinds: validator/pii-scan, audit/logger, identity/jwt, +// delegator/oauth. Bundled PDP kind: cedar-direct. +// +// 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 +// flow instead: +// +// mgr, _ := NewPluginManagerDefault() +// mgr.EnableAPL() +// mgr.LoadConfig(yaml) +// mgr.Initialize() +// +// On failure the returned error wraps a typed sentinel +// (ErrCpexInvalidHandle, ErrCpexPanic). +func (m *PluginManager) EnableAPL() 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) + return errorFromRC(int(rc), "EnableAPL") +} diff --git a/go/cpex/apl_test.go b/go/cpex/apl_test.go new file mode 100644 index 00000000..d3510762 --- /dev/null +++ b/go/cpex/apl_test.go @@ -0,0 +1,73 @@ +// Location: ./go/cpex/apl_test.go +// Copyright 2025 +// 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: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "testing" +) + +// 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. +func TestEnableAPLLoadsAplConfig(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.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 + apl: + policy: + - "plugin(auditor)" +` + if err := mgr.LoadConfig(yaml); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := mgr.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 !mgr.HasHooksFor("cmf.tool_pre_invoke") { + t.Error("expected cmf.tool_pre_invoke hook registered after APL load") + } +} + +// TestEnableAPLAfterShutdown verifies the typed handle error is returned +// when EnableAPL is called on a shut-down manager. +func TestEnableAPLAfterShutdown(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + err = mgr.EnableAPL() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } +} diff --git a/go/cpex/constants.go b/go/cpex/constants.go index 45ce3858..3fb39e23 100644 --- a/go/cpex/constants.go +++ b/go/cpex/constants.go @@ -20,6 +20,10 @@ const ( PayloadGeneric uint8 = 0 // PayloadCMFMessage is a CMF MessagePayload. 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. + PayloadIdentity uint8 = 2 ) // ContentType values — the discriminator for ContentPart's tagged union. diff --git a/go/cpex/go.mod b/go/cpex/go.mod index d71e10b0..c3c06bc9 100644 --- a/go/cpex/go.mod +++ b/go/cpex/go.mod @@ -1,4 +1,4 @@ -module github.com/contextforge-org/contextforge-plugins-framework/go/cpex +module github.com/contextforge-org/cpex/go/cpex go 1.25.4 diff --git a/go/cpex/identity.go b/go/cpex/identity.go new file mode 100644 index 00000000..220df7cf --- /dev/null +++ b/go/cpex/identity.go @@ -0,0 +1,83 @@ +// Location: ./go/cpex/identity.go +// Copyright 2025 +// 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). +// +// 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( +// cpex.HookIdentityResolve, cpex.PayloadIdentity, idp, ext, nil) +// // On success the resolved IdentityPayload comes back as res.ModifiedPayload. +// resolved, _ := cpex.DeserializePayload[cpex.IdentityPayload](res) +// // resolved.Subject now 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. + +package cpex + +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. +const HookIdentityResolve = "identity.resolve" + +// TokenSource values — where a credential was extracted from. Wire form +// is snake_case to match the Rust TokenSource enum +// (#[serde(rename_all = "snake_case")]). +const ( + TokenSourceBearer = "bearer" + TokenSourceUserToken = "user_token" + TokenSourceMTLS = "mtls" + TokenSourceSpiffeJwtSvid = "spiffe_jwt_svid" + TokenSourceAPIKey = "api_key" +) + +// 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. +// +// raw_token is intentionally absent: it is #[serde(skip)] on the Rust +// side (zeroized, never serialized). Tokens travel in Headers — each +// jwt resolver reads its configured header (X-User-Token, Authorization) +// 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. +type IdentityPayload struct { + // ----- Input ----- + Source string `msgpack:"source"` + SourceHeader string `msgpack:"source_header,omitempty"` + Headers map[string]string `msgpack:"headers,omitempty"` + ClientHost string `msgpack:"client_host,omitempty"` + ClientPort uint16 `msgpack:"client_port,omitempty"` + + // ----- Output ----- + Subject *SubjectExtension `msgpack:"subject,omitempty"` + Client msgpack.RawMessage `msgpack:"client,omitempty"` + CallerWorkload msgpack.RawMessage `msgpack:"caller_workload,omitempty"` + Delegation msgpack.RawMessage `msgpack:"delegation,omitempty"` + RawCredentials msgpack.RawMessage `msgpack:"raw_credentials,omitempty"` + ResolvedAt string `msgpack:"resolved_at,omitempty"` + RawClaims map[string]any `msgpack:"raw_claims,omitempty"` +} + +// NewIdentityPayload builds an input payload for identity.resolve. The +// header map should carry the inbound request's auth headers (lowercased +// keys — the resolvers look their configured header up case-folded). +func NewIdentityPayload(source string, headers map[string]string) IdentityPayload { + if source == "" { + source = TokenSourceBearer + } + return IdentityPayload{Source: source, Headers: headers} +} diff --git a/go/cpex/manager.go b/go/cpex/manager.go index 911bb7ec..00d7fcf3 100644 --- a/go/cpex/manager.go +++ b/go/cpex/manager.go @@ -1,7 +1,7 @@ // Location: ./go/cpex/manager.go // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor +// Authors: Teryl Taylor, Fred Araujo // // PluginManager — Go wrapper for the CPEX plugin runtime. // @@ -75,6 +75,18 @@ extern int cpex_invoke( CpexContextTable* context_table_out, CpexBackgroundTasks* bg_handle_out ); +extern int cpex_invoke_resolved( + CpexManager mgr, + 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, + 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 +); extern int cpex_wait_background( CpexManager mgr, CpexBackgroundTasks bg_handle, @@ -341,19 +353,18 @@ func (m *PluginManager) InvokeByName( cHookName := C.CString(hookName) defer C.free(unsafe.Pointer(cHookName)) - // Pass the context-table handle to Rust but DO NOT nil our local - // reference until we know Rust succeeded. Rust consumes the handle - // only at the moment of invoke (after all input validation), so - // pre-invoke failures (bad payload, bad extensions, etc.) leave - // the handle untouched and the caller's ContextTable remains valid. - // - // Caveat: on a post-invoke failure (rare — only result-serialization - // OOM), Rust has consumed the box but doesn't write ctOut, so the - // caller's ContextTable handle becomes dangling. The caller should - // not reuse a ContextTable after an InvokeByName error. + // Pass the context-table handle to Rust. Per the post-P0-1 FFI + // contract, `cpex_invoke` takes ownership of `ctHandle` + // UNCONDITIONALLY on entry — same pattern as `cpex_wait_background` + // with `bg_handle`. We nil our local reference immediately so the + // caller's `ContextTable` can't be accidentally reused after this + // call (its underlying Box is gone regardless of the eventual rc). + // On RC_OK a fresh handle lands in `ctOut`; on any error path + // `ctOut` stays nil and the caller's context-table chain ends here. var ctHandle C.CpexContextTable if contextTable != nil { ctHandle = contextTable.handle + contextTable.handle = nil } var resultPtr *C.uint8_t @@ -386,16 +397,13 @@ func (m *PluginManager) InvokeByName( ) if rc != 0 { + // `ctOut` is null on every non-OK return per the post-P0-1 + // contract. The caller's `contextTable.handle` is already nil + // (we cleared it above before the call), so there's no + // dangling-handle risk on error paths. return nil, nil, nil, errorFromRC(int(rc), "InvokeByName") } - // Rust succeeded — it consumed ctHandle and produced ctOut. - // NOW it's safe to nil the caller's reference (the original Box - // was consumed by Rust; its successor is in ctOut). - if contextTable != nil { - contextTable.handle = nil - } - // Deserialize result from MessagePack resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) @@ -419,6 +427,116 @@ func (m *PluginManager) InvokeByName( return &result, resultCT, bg, nil } +// InvokeResolved runs identity.resolve and the named hook in a single FFI +// call. The resolved Extensions — including raw_credentials, whose inbound +// tokens are skip-serialized and so can't survive an FFI round-trip — are +// threaded from identity into the hook in Rust memory. This lets an +// out-of-process host drive delegate() flows that need the inbound bearer +// 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 +// registered, this degrades to a plain InvokeByName(hookName, ...). +// Otherwise the semantics (ContextTable threading, ownership, result +// shape) match InvokeByName. +func (m *PluginManager) InvokeResolved( + identity IdentityPayload, + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, nil, nil, fmt.Errorf("InvokeResolved: %w", ErrCpexInvalidHandle) + } + + idBytes, err := msgpack.Marshal(identity) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: identity marshal failed: %w", err) + } + payloadBytes, err := msgpack.Marshal(payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: payload marshal failed: %w", err) + } + var extBytes []byte + if extensions != nil { + extBytes, err = msgpack.Marshal(extensions) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: extensions marshal failed: %w", err) + } + } + + cHookName := C.CString(hookName) + defer C.free(unsafe.Pointer(cHookName)) + + var ctHandle C.CpexContextTable + if contextTable != nil { + ctHandle = contextTable.handle + } + + var resultPtr *C.uint8_t + var resultLen C.int + var ctOut C.CpexContextTable + var bgOut C.CpexBackgroundTasks + + var idPtr *C.uint8_t + if len(idBytes) > 0 { + idPtr = (*C.uint8_t)(unsafe.Pointer(&idBytes[0])) + } + var payloadPtr *C.uint8_t + if len(payloadBytes) > 0 { + payloadPtr = (*C.uint8_t)(unsafe.Pointer(&payloadBytes[0])) + } + var extPtr *C.uint8_t + var extLen C.int + if len(extBytes) > 0 { + extPtr = (*C.uint8_t)(unsafe.Pointer(&extBytes[0])) + extLen = C.int(len(extBytes)) + } + + rc := C.cpex_invoke_resolved( + m.handle, + idPtr, C.int(len(idBytes)), + cHookName, C.int(len(hookName)), + C.uint8_t(payloadType), + payloadPtr, C.int(len(payloadBytes)), + extPtr, extLen, + ctHandle, + &resultPtr, &resultLen, + &ctOut, + &bgOut, + ) + + if rc != 0 { + 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) + + var result PipelineResult + if err := msgpack.Unmarshal(resultBytes, &result); err != nil { + return nil, nil, nil, fmt.Errorf("cpex: result unmarshal failed: %w", err) + } + + resultCT := &ContextTable{handle: ctOut} + runtime.SetFinalizer(resultCT, func(ct *ContextTable) { + ct.Close() + }) + + bg := &BackgroundTasks{handle: bgOut, mgr: m} + + return &result, resultCT, bg, nil +} + // Invoke is the typed invoke path. Calls InvokeByName and deserializes // the modified payload and extensions into concrete Go types. // diff --git a/go/cpex/manager_test.go b/go/cpex/manager_test.go index f5b31c5a..7dff38aa 100644 --- a/go/cpex/manager_test.go +++ b/go/cpex/manager_test.go @@ -1173,3 +1173,113 @@ func TestLoadConfigInvalidYAML(t *testing.T) { t.Error("expected error for invalid YAML") } } + +// TestInvokeByNameErrorDoesNotUAFContextTable is the regression guard +// for P0-1. Pre-fix, `cpex_invoke` consumed the input ContextTable's +// Box mid-function but didn't write *context_table_out on +// RC_TIMEOUT / RC_PANIC / RC_PARSE_ERROR — the Go wrapper kept its +// stale handle and a subsequent Close() called +// cpex_release_context_table on already-freed memory. +// +// Post-fix, the Go wrapper nils its `contextTable.handle` immediately +// after handing it to Rust (mirroring the bg_handle pattern), so even +// if Rust errors out without producing a replacement, no dangling +// handle survives. +// +// This test: +// 1. Performs a successful invoke to get a real ContextTable. +// 2. Calls InvokeByName again with that ContextTable PLUS an +// invalid payload_type that forces Rust to return RC_PARSE_ERROR +// AFTER the consumption point. +// 3. Confirms the second call errored (sanity). +// 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // 1. Successful first invoke — gives us a real, Rust-allocated + // 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) + 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") + } + + // 2. Second invoke with an UNKNOWN payload_type (99). The Rust + // side validates payload_type against its registry; an + // unknown value forces a RC_PARSE_ERROR return. Critically, + // that error path is now POST-consumption of the input + // context_table. + const unknownPayloadType uint8 = 99 + _, _, _, err = mgr.InvokeByName("hook2", unknownPayloadType, payload, &Extensions{}, ctxTable) + if err == nil { + t.Fatal("expected error from invoke with unknown payload_type") + } + + // 3. Per the P0-1 contract, ctxTable's handle was nil'd in Go + // *before* the C call returned. So whether or not Rust wrote + // *context_table_out, our local handle is nil. + if ctxTable.handle != nil { + t.Errorf("input ContextTable.handle should be nil after invoke error; got %p", ctxTable.handle) + } + + // 4. The actual UAF check: Close() must be safe. Pre-fix this + // called cpex_release_context_table on already-freed memory. + // Post-fix, Close() short-circuits on a nil handle and is a + // no-op. Either it crashes (fail) or it doesn't (pass). + ctxTable.Close() +} + +// TestInvokeByNameConsumesContextTableOnRcError pins the other half +// of the P0-1 contract — even when the manager 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() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + mgr.Shutdown() + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + if err != nil { + mgr.Shutdown() + t.Fatalf("first invoke failed: %v", err) + } + bg.Close() + + // Shut down the manager — 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() + + _, _, _, err = mgr.InvokeByName("hook2", PayloadGeneric, payload, &Extensions{}, ctxTable) + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle after shutdown, got %v", err) + } + + // Even though the Go-side short-circuit didn't transit our + // handle to Rust, Close() must still be safe — it's a legal + // thing for callers to do. + ctxTable.Close() +} diff --git a/scripts/download-ffi-artifact.sh b/scripts/download-ffi-artifact.sh new file mode 100755 index 00000000..a72eb5cc --- /dev/null +++ b/scripts/download-ffi-artifact.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Location: ./scripts/download-ffi-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Consumer-facing script. Downloads a published libcpex_ffi.a +# release tarball from the CPEX GitHub Releases, verifies its +# sha256 + cosign signature, and unpacks it into a directory ready +# for cgo to link from. +# +# Intended for use in downstream Dockerfiles and CI jobs that don't +# want a Rust toolchain. Vendor this file (or fetch it pinned by tag +# from raw.githubusercontent.com) and call it before `go build`. +# +# Inputs (env or flag-equivalent CLI args): +# CPEX_FFI_VERSION Required. Tag of the release, e.g. v0.9.0. +# CPEX_FFI_TARGET Optional. Tuple name (linux-amd64-gnu, +# linux-arm64-gnu, linux-amd64-musl, +# linux-arm64-musl, darwin-arm64). Auto-detected +# from `uname -s` / `uname -m` + libc probe if unset. +# CPEX_FFI_DEST Optional. Destination directory. Defaults to +# ./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/. +# CPEX_FFI_REPO Optional. GitHub owner/repo override. Defaults +# to contextforge-org/cpex. +# CPEX_FFI_BASE_URL Optional. Full URL prefix override (skips the +# github.com/releases/download URL construction). +# Used for local file:// dry-runs. +# CPEX_FFI_SKIP_COSIGN +# Optional. Set to "1" to skip cosign verification. +# sha256 verification is never skipped. Only for +# air-gapped / offline environments where cosign +# cannot reach Sigstore. Document the risk. +# +# Output: +# Prints the absolute destination directory to stdout on success. +# Consumers capture it with $(bash download-ffi-artifact.sh) and +# pass to CGO_LDFLAGS as `-L${dir} -lcpex_ffi`. +# +# Idempotency: +# If ${dest}/VERSION exists and its first "version=..." line matches +# CPEX_FFI_VERSION, the script exits 0 without re-downloading. + +set -euo pipefail + +err() { echo "download-ffi-artifact: error: $*" >&2; exit 1; } +info() { echo "download-ffi-artifact: $*" >&2; } # stderr — stdout is the dest path + +: "${CPEX_FFI_VERSION:?CPEX_FFI_VERSION is required (e.g. v0.9.0)}" +CPEX_FFI_REPO="${CPEX_FFI_REPO:-contextforge-org/cpex}" + +# Detect target tuple if not provided. Inverse of the mapping in +# build-artifact.sh. +detect_tuple() { + local os arch libc="" + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + # Probe for musl vs gnu. ldd --version writes to stderr; + # musl's ldd prints "musl libc" on stderr too, gnu prints + # "GLIBC". Fallback heuristic: presence of /lib/ld-musl-*. + if (ldd --version 2>&1 || true) | grep -qi musl; then + libc="musl" + elif compgen -G "/lib/ld-musl-*" >/dev/null; then + libc="musl" + else + libc="gnu" + fi + case "$arch" in + x86_64) echo "linux-amd64-${libc}" ;; + aarch64) echo "linux-arm64-${libc}" ;; + *) err "unsupported linux arch: $arch" ;; + esac + ;; + Darwin) + case "$arch" in + arm64) echo "darwin-arm64" ;; + x86_64) echo "darwin-amd64" ;; + *) err "unsupported darwin arch: $arch" ;; + esac + ;; + *) err "unsupported OS: $os" ;; + esac +} + +CPEX_FFI_TARGET="${CPEX_FFI_TARGET:-$(detect_tuple)}" +CPEX_FFI_DEST="${CPEX_FFI_DEST:-./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}}" + +info "version=$CPEX_FFI_VERSION target=$CPEX_FFI_TARGET dest=$CPEX_FFI_DEST" + +# Idempotency: a successful prior run leaves a VERSION file whose +# first line is "version=". If it matches, we're done. +if [[ -f "${CPEX_FFI_DEST}/VERSION" ]]; then + existing="$(head -n1 "${CPEX_FFI_DEST}/VERSION" | sed -E 's/^version=//')" + if [[ "$existing" == "$CPEX_FFI_VERSION" ]]; then + info "already present at $CPEX_FFI_DEST (version=$existing); skipping download" + cd "$CPEX_FFI_DEST" && pwd + exit 0 + fi + info "existing VERSION ($existing) != requested ($CPEX_FFI_VERSION); re-downloading" +fi + +TARBALL_NAME="cpex-ffi-${CPEX_FFI_VERSION}-${CPEX_FFI_TARGET}.tar.gz" +BASE_URL="${CPEX_FFI_BASE_URL:-https://github.com/${CPEX_FFI_REPO}/releases/download/${CPEX_FFI_VERSION}}" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +fetch() { + local name="$1" + local url="${BASE_URL}/${name}" + info " GET $url" + if [[ "$url" == file://* ]]; then + cp "${url#file://}" "${WORK_DIR}/${name}" \ + || err "failed to copy from $url" + else + curl -fsSL --retry 3 --retry-delay 2 -o "${WORK_DIR}/${name}" "$url" \ + || err "failed to download $url" + fi +} + +info "downloading release assets" +fetch "$TARBALL_NAME" +fetch "${TARBALL_NAME}.sha256" + +# sha256 verification — non-negotiable. The .sha256 file contains +# " "; sha256sum -c reads it and checks. macOS's +# shasum -a 256 -c uses the same format. +info "verifying sha256" +if command -v sha256sum >/dev/null; then + (cd "$WORK_DIR" && sha256sum -c "${TARBALL_NAME}.sha256") +else + (cd "$WORK_DIR" && shasum -a 256 -c "${TARBALL_NAME}.sha256") +fi + +# cosign verification — opt-out only. The certificate identity is the +# workflow path; the regex permits any tag ref so re-tagged releases +# still verify. The issuer is pinned to GitHub's OIDC issuer to +# prevent Sigstore certs from other providers from passing. +if [[ "${CPEX_FFI_SKIP_COSIGN:-0}" == "1" ]]; then + info "WARN: skipping cosign verification (CPEX_FFI_SKIP_COSIGN=1)" +else + command -v cosign >/dev/null || err "cosign is required for signature verification (or set CPEX_FFI_SKIP_COSIGN=1 to bypass — not recommended)" + fetch "${TARBALL_NAME}.sig" + fetch "${TARBALL_NAME}.crt" + info "verifying cosign signature" + cosign verify-blob \ + --certificate "${WORK_DIR}/${TARBALL_NAME}.crt" \ + --signature "${WORK_DIR}/${TARBALL_NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/${CPEX_FFI_REPO}/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${WORK_DIR}/${TARBALL_NAME}" \ + >/dev/null \ + || err "cosign verification failed" +fi + +# Unpack into the destination, replacing any prior contents at that +# version (the idempotency check above already handled the +# already-present case). +info "unpacking into $CPEX_FFI_DEST" +mkdir -p "$CPEX_FFI_DEST" +# Clear stale files from a partial earlier run; safe because we only +# touch our own version-stamped dir. +find "$CPEX_FFI_DEST" -mindepth 1 -delete +tar xzf "${WORK_DIR}/${TARBALL_NAME}" -C "$CPEX_FFI_DEST" + +# Print the absolute destination so consumer scripts can capture it. +(cd "$CPEX_FFI_DEST" && pwd) +info "done" diff --git a/scripts/release/build-artifact.sh b/scripts/release/build-artifact.sh new file mode 100755 index 00000000..01b6afdc --- /dev/null +++ b/scripts/release/build-artifact.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/build-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Build one libcpex_ffi.a for a given Rust target triple and stage it +# into a release tarball under dist/. +# +# Invoked once per matrix tuple by .github/workflows/release-ffi.yaml. +# Safe to run locally for the host tuple to validate the bundle shape. +# +# Inputs (env): +# TARGET Required. Rust target triple, e.g. x86_64-unknown-linux-gnu. +# VERSION Required in CI. Git tag, e.g. v0.9.0. Falls back to +# `git describe --tags --dirty` for local invocations. +# DIST_DIR Optional. Output dir for tarball + .sha256. Defaults to ./dist. +# USE_CROSS Optional. If "1", build with `cross` instead of `cargo`. +# Required for cross-compiling musl/arm targets without a +# pre-installed sysroot. +# +# Outputs: +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz.sha256 + +set -euo pipefail + +err() { echo "build-artifact: error: $*" >&2; exit 1; } +info() { echo "build-artifact: $*"; } + +: "${TARGET:?TARGET is required (e.g. x86_64-unknown-linux-gnu)}" +DIST_DIR="${DIST_DIR:-./dist}" +USE_CROSS="${USE_CROSS:-0}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# Version resolution. CI sets VERSION from the tag; locally fall back +# to git-describe so dev iterations get a sensible bundle name. +if [[ -z "${VERSION:-}" ]]; then + VERSION="$(git describe --tags --dirty --always 2>/dev/null || echo "v0.0.0-dev")" + info "VERSION not set; using git-describe fallback: $VERSION" +fi + +# Map Rust target triple → our tuple naming. This is the contract +# downstream consumers' download-ffi-artifact.sh inverts via uname. +case "$TARGET" in + x86_64-unknown-linux-gnu) TUPLE="linux-amd64-gnu" ;; + aarch64-unknown-linux-gnu) TUPLE="linux-arm64-gnu" ;; + x86_64-unknown-linux-musl) TUPLE="linux-amd64-musl" ;; + aarch64-unknown-linux-musl) TUPLE="linux-arm64-musl" ;; + aarch64-apple-darwin) TUPLE="darwin-arm64" ;; + x86_64-apple-darwin) TUPLE="darwin-amd64" ;; + *) err "unsupported TARGET: $TARGET (add a case in build-artifact.sh)" ;; +esac + +# Read FFI_ABI_VERSION from the crate source. Single source of truth — +# bumps in lib.rs flow into the bundle without a separate config edit. +ABI_LINE="$(grep -E '^pub const FFI_ABI_VERSION: u32 = [0-9]+;' \ + crates/cpex-ffi/src/lib.rs || true)" +[[ -n "$ABI_LINE" ]] || err "could not find FFI_ABI_VERSION in crates/cpex-ffi/src/lib.rs" +FFI_ABI="$(echo "$ABI_LINE" | sed -E 's/.*= ([0-9]+);.*/\1/')" +[[ "$FFI_ABI" =~ ^[0-9]+$ ]] || err "extracted FFI_ABI is not an integer: $FFI_ABI" + +info "TARGET=$TARGET TUPLE=$TUPLE VERSION=$VERSION FFI_ABI=$FFI_ABI" + +# Build. `cross` swaps in a containerized toolchain with the right +# sysroot/glibc/musl for the target — used for arm and musl from x86_64 +# linux runners. Local host builds use plain cargo. +if [[ "$USE_CROSS" == "1" ]]; then + command -v cross >/dev/null || err "USE_CROSS=1 but cross is not installed" + info "building with cross" + cross build --release --locked --target "$TARGET" -p cpex-ffi +else + info "building with cargo" + cargo build --release --locked --target "$TARGET" -p cpex-ffi +fi + +ARTIFACT_PATH="target/${TARGET}/release/libcpex_ffi.a" +[[ -f "$ARTIFACT_PATH" ]] || err "expected artifact missing: $ARTIFACT_PATH" + +# Stage into a temp dir, tar from there so the archive has no leading +# directory and tools like the download script can `tar xzf` flat into +# any destination. +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT + +cp "$ARTIFACT_PATH" "$STAGE_DIR/libcpex_ffi.a" +cp LICENSE "$STAGE_DIR/LICENSE" + +GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +cat > "$STAGE_DIR/VERSION" < "$STAGE_DIR/FFI_ABI" + +mkdir -p "$DIST_DIR" +TARBALL_NAME="cpex-ffi-${VERSION}-${TUPLE}.tar.gz" +TARBALL_PATH="${DIST_DIR}/${TARBALL_NAME}" + +# tar -C ${STAGE_DIR} . produces a flat archive (no leading dir). +# --owner / --group / --mtime would help reproducibility but BSD/GNU +# tar flag divergence makes that finicky; --locked + cargo gives us +# the most important reproducibility guarantee. +tar -czf "$TARBALL_PATH" -C "$STAGE_DIR" . + +# sha256 companion. Recompute on the consumer side as the integrity gate. +# Use coreutils sha256sum if present (linux), shasum -a 256 otherwise (macOS). +if command -v sha256sum >/dev/null; then + (cd "$DIST_DIR" && sha256sum "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +else + (cd "$DIST_DIR" && shasum -a 256 "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +fi + +info "wrote $TARBALL_PATH" +info "wrote ${TARBALL_PATH}.sha256" diff --git a/scripts/release/sign-artifact.sh b/scripts/release/sign-artifact.sh new file mode 100755 index 00000000..f306311a --- /dev/null +++ b/scripts/release/sign-artifact.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/sign-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Sign every tarball + SHA256SUMS file in DIST_DIR with cosign keyless +# (Sigstore Fulcio + Rekor). Produces a .sig and .crt next to each +# signed file so downstream consumers can verify without fetching keys. +# +# Invoked once by the sign-and-release job in release-ffi.yaml after +# all matrix-built tarballs are downloaded into dist/. Requires the +# workflow to have `id-token: write` so cosign can obtain the GitHub +# Actions OIDC token for keyless signing. +# +# Inputs (env): +# DIST_DIR Optional. Directory containing .tar.gz / SHA256SUMS files +# to sign. Defaults to ./dist. +# +# Outputs: +# For every cpex-ffi-*.tar.gz or cpex-ffi-*-SHA256SUMS in DIST_DIR: +# .sig +# .crt + +set -euo pipefail + +err() { echo "sign-artifact: error: $*" >&2; exit 1; } +info() { echo "sign-artifact: $*"; } + +DIST_DIR="${DIST_DIR:-./dist}" +[[ -d "$DIST_DIR" ]] || err "DIST_DIR does not exist: $DIST_DIR" + +command -v cosign >/dev/null || err "cosign is required (install before running)" + +# Sign tarballs and the aggregate SHA256SUMS bundle (if present). The +# per-tarball .sha256 companions are not signed individually — the +# SHA256SUMS file is the signed integrity manifest. The download +# script verifies the tarball's own signature directly, so the +# per-tarball .sha256 is convenience-only. +shopt -s nullglob +TO_SIGN=( "$DIST_DIR"/cpex-ffi-*.tar.gz "$DIST_DIR"/cpex-ffi-*-SHA256SUMS ) +shopt -u nullglob + +[[ ${#TO_SIGN[@]} -gt 0 ]] || err "no files to sign in $DIST_DIR" + +info "signing ${#TO_SIGN[@]} file(s) with cosign keyless" + +for f in "${TO_SIGN[@]}"; do + [[ -f "$f" ]] || continue + info " signing $(basename "$f")" + # --yes skips the interactive "open browser?" prompt — required for + # CI. The OIDC token is sourced automatically from the GHA env + # (ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN). --output-* writes the + # detached signature + cert so verifiers don't need Rekor lookups + # for the basics, though Rekor is still queried for transparency. + cosign sign-blob --yes \ + --output-signature "${f}.sig" \ + --output-certificate "${f}.crt" \ + "$f" +done + +info "done; signed ${#TO_SIGN[@]} file(s)" From daa4f7d122fe6369cd3dcb5a792dc62d49e145b7 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 22:37:08 +0200 Subject: [PATCH 09/64] fix: subject-bind client-supplied session ids in the resolver (#66) * feat: initial Rust Core (cpex-core and cpex-sdk) (#13) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor * feat: CPEX Rust config (#38) * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor * feat: RUST with CMF and extensions. (#44) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor * feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor * feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor * feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Teryl Taylor Co-authored-by: Frederico Araujo * feat: cgo Go bindings (#45) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor * feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor * feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor * feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor * feat: added cgo and golang bindings, examples and readme. Signed-off-by: Teryl Taylor * address P0/P1/P2 review findings (except #17) Signed-off-by: Teryl Taylor * fix: address remaining P2/P3 review findings + testing gaps Signed-off-by: Teryl Taylor * docs: add CPEX Go public API spec Signed-off-by: Frederico Araujo * docs: renamed document Signed-off-by: Frederico Araujo * feat(cpex-rust): CGO review passes 1-11 + lint cleanup + Makefile targets Signed-off-by: Teryl Taylor * fix: address linting issues, updated makefile to support building examples. Signed-off-by: Teryl Taylor * docs: updated the go spec to reflect recent changes. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Teryl Taylor Co-authored-by: Frederico Araujo * docs: intial rust specification (#50) Co-authored-by: Teryl Taylor * feat: change Plugin handler to async for performance (#49) Co-authored-by: Teryl Taylor * fix: missing cmf-demo main.go file and gitignore fix that missed it (#52) Co-authored-by: Teryl Taylor * feat: initial APL Rust implementation (#60) * fix: initial revision APL. * feat: apl-cpex bridge crate + plugin-registry-driven hook dispatch * feat: add support for plugin calling in APL routes. * feat: add more APL plugin support, unified config * feat: added cedar direct PDP. * feat: add identity hook and extensions. * feat: added token delegation hooks and tests. * feat: added plugin for jwt token identity, oauth and biscuit delegation, cedarling PDP. Signed-off-by: Teryl Taylor * fix: updated identity and delegation to support keycloak. added delegate() function, and identity sections. * fix: added some sample plugins, added updates to support cedar. Signed-off-by: Teryl Taylor * feat: added session support, serialize and parallel and full effects capabilities. * feat: add ffi pre-built .a library Signed-off-by: Frederico Araujo * chore: add workflow_dispatch target Signed-off-by: Frederico Araujo * fix: critical and high issues from review. * feat: add APL FFI and go bindings Signed-off-by: Frederico Araujo * chore: add musl tools to musl runners Signed-off-by: Frederico Araujo * fix: potential double free after use bug. * chore: update Go module paths after repo rename to cpex * feat: map identity extension into cpex ffi Signed-off-by: Frederico Araujo * feat: add cpex_invoke_resolved abi Signed-off-by: Frederico Araujo * fix: has_hook_for handling Signed-off-by: Frederico Araujo * chore: update headers Signed-off-by: Frederico Araujo --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Frederico Araujo * fix: session binding Signed-off-by: Frederico Araujo * chore: updated comments Signed-off-by: Frederico Araujo * tests: added more session tests for Tier 1 ids. --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: terylt <30874627+terylt@users.noreply.github.com> Co-authored-by: Teryl Taylor --- CHANGELOG.md | 11 - crates/apl-cpex/src/session_resolver.rs | 326 +++++++++++++++++++--- crates/apl-cpex/tests/end_to_end_route.rs | 192 ++++++++----- 3 files changed, 408 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5596e524..36f75689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,17 +45,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped in lockstep. -## [0.1.1] - 2026-06-04 - -### Added - -- Plugin bundling, catalog, installation and versioning ([#31](https://github.com/contextforge-org/cpex/pull/31)) - -### Fixed - -- Implement `__eq__` and `__ne__` for CopyOnWriteDict ([#55](https://github.com/contextforge-org/cpex/pull/55)) -- Respect `PLUGINS_LOG_LEVEL` environment variable in all runtime.py files ([#48](https://github.com/contextforge-org/cpex/pull/48)) - ## [0.1.0] - 2026-05-05 ### Added diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs index 77615c96..1d4ed95b 100644 --- a/crates/apl-cpex/src/session_resolver.rs +++ b/crates/apl-cpex/src/session_resolver.rs @@ -22,17 +22,19 @@ // // 0. `agent` — `AgentExtension.session_id`. A *pre-resolved* // value: an upstream plugin or middleware decided what the -// session is and wrote it here. Highest priority because it -// represents authority, not derivation — overriding this with a -// derived value would discard that upstream decision. Plugins -// that need bespoke session resolution (e.g., reading from a -// separate session-management service) write here and let the -// resolver pick it up. +// 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"]`. +// `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 @@ -78,6 +80,33 @@ impl SessionSource { } } +/// 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 Tiers 2/3, which also +/// require 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 @@ -91,23 +120,41 @@ impl SessionSource { /// 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)> { - // Tier 0: pre-resolved by an upstream plugin. Authoritative — - // wins over every derived tier so plugin-supplied custom session - // resolution isn't silently overridden by a derived hash. + // The authenticated subject, populated by the identity resolvers + // (apl-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() { - return Some((sid.to_string(), SessionSource::Agent)); + if let Some(bound) = subject_scoped(subject_id, sid) { + return Some((bound, SessionSource::Agent)); + } } } } - // Tier 1: explicit JWT claim. + // 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() { - return Some((sid.clone(), SessionSource::TokenClaim)); + if let Some(bound) = subject_scoped(subject_id, sid) { + return Some((bound, SessionSource::TokenClaim)); + } } } } @@ -134,18 +181,7 @@ pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> { .and_then(|w| w.client_id.as_deref()) .unwrap_or("-"); let raw = format!("{}:{}:{}", sub, actor, aud); - let mut hasher = Sha256::new(); - hasher.update(raw.as_bytes()); - // 16 hex chars = 64 bits — plenty for the workload sizes - // CPEX targets, matches the Python implementation's - // `hexdigest()[:16]`. - let digest = hasher.finalize(); - let hex: String = digest - .iter() - .take(8) - .map(|b| format!("{:02x}", b)) - .collect(); - return Some((hex, SessionSource::Identity)); + return Some((short_hash(&raw), SessionSource::Identity)); } } @@ -184,20 +220,67 @@ mod tests { } } + // 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() + } + } + // --- Tier 0: agent (pre-resolved) --- #[test] - fn tier0_agent_session_id_hits_first() { + 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() }; - - let (sid, src) = resolve_session(&ext).expect("should resolve"); - assert_eq!(sid, "sess-upstream"); - assert_eq!(src, SessionSource::Agent); + assert!(resolve_session(&ext).is_none()); } #[test] @@ -209,14 +292,21 @@ mod tests { 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() }; - assert!(resolve_session(&ext).is_none()); + // 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. + // 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 { @@ -233,8 +323,42 @@ mod tests { }; let (sid, src) = resolve_session(&ext).unwrap(); - assert_eq!(sid, "from-agent"); assert_eq!(src, SessionSource::Agent); + assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap()); + } + + #[test] + fn tier0_wins_over_identity() { + // T0 (agent.session_id) must win over T2 (identity triple) when + // both are available. Pins the tier priority explicitly so a + // future refactor of the resolver's walk order 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, + "T0 must win over T2 when both are available", + ); + assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap()); } // --- Tier 1: token_claim --- @@ -251,8 +375,13 @@ mod tests { let ext = extensions_with_security(sec); let (sid, src) = resolve_session(&ext).expect("should resolve"); - assert_eq!(sid, "sess-from-token-789"); 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] @@ -261,17 +390,112 @@ mod tests { // 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 Finding 2 guarantee for T1. 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 T0 cross-principal test. + 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`). T1 has no safe scope without a + // subject — must fall through. T2 also requires a subject and + // therefore 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. T1 must win over T2. Pins the tier priority + // explicitly — the existing happy-path test happens to omit + // T2 inputs, so without this T1>T2 priority is only implicit. let sec = SecurityExtension { subject: Some(subject_with_claims( Some("alice"), - &[("session_id", "")], + &[("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 (_, src) = resolve_session(&ext).expect("should fall through to identity"); - assert_eq!(src, SessionSource::Identity); + let (sid, src) = resolve_session(&ext).unwrap(); + assert_eq!( + src, + SessionSource::TokenClaim, + "T1 must win over T2 when both are available", + ); + assert_eq!(sid, subject_scoped(Some("alice"), "from-claim").unwrap()); } // --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent --- @@ -387,6 +611,34 @@ mod tests { assert!(resolve_session(&ext).is_none()); } + // --- Wire-format documentation --- + + #[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", + ); + } + // --- Spoofing guard (regression test for P0-2) --- #[test] diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs index 184c8149..b06c64f1 100644 --- a/crates/apl-cpex/tests/end_to_end_route.rs +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -38,6 +38,29 @@ use apl_core::{ use apl_cpex::{CmfPluginInvoker, DispatchCache, MemorySessionStore, SessionStore}; +// 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. @@ -154,10 +177,7 @@ impl PluginFactory for DenyPluginFactory { // Helpers // --------------------------------------------------------------------- -async fn manager_with( - kind: &str, - factory: Box, -) -> Arc { +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); @@ -204,19 +224,28 @@ routes: 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); + let invoker = Arc::new( + CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + cmf_payload(), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await, + ); 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)).await; + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(Arc::new(NoopDelegationInvoker) as Arc), + ) + .await; assert_eq!(decision.decision, Decision::Allow); assert!(decision.taints.is_empty()); @@ -245,19 +274,28 @@ routes: 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); + let invoker = Arc::new( + CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + cmf_payload(), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await, + ); 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)).await; + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(Arc::new(NoopDelegationInvoker) as Arc), + ) + .await; match decision.decision { Decision::Deny { @@ -307,9 +345,7 @@ impl HookHandler for TaintingPlugin { // 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); + let security = owned.security.get_or_insert_with(Default::default); security.add_label("PII"); PluginResult::modify_extensions(owned) } @@ -364,29 +400,27 @@ routes: let cache = DispatchCache::new(); let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; - // Session id pinned via tier-0 (agent.session_id) — lets the test - // specify an exact value without faking the identity hash. - let mut agent = cpex_core::extensions::AgentExtension::default(); - agent.session_id = Some("sess-taint-test".into()); - let extensions = Extensions { - agent: Some(Arc::new(agent)), - ..Default::default() - }; + // 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); + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); 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)).await; + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(Arc::new(NoopDelegationInvoker) as Arc), + ) + .await; // Decision flows through allow (plugin's modify_extensions doesn't // halt the pipeline). @@ -400,7 +434,11 @@ routes: // evaluate_steps_inner accumulator → // StepsEvaluation.taints → // evaluate_route → RouteDecision.taints - assert_eq!(decision.taints.len(), 1, "expected one taint event from tagger plugin"); + 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]); @@ -409,7 +447,7 @@ routes: // evaluation; new labels (vs the post-hydration snapshot) land in // the store under the request's session_id. invoker.persist_session().await; - let stored = session_store.load_labels("sess-taint-test").await; + let stored = session_store.load_labels(&session_key).await; assert_eq!(stored, vec!["PII".to_string()]); } @@ -418,9 +456,11 @@ 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("sess-existing", &["PRIOR".to_string()]) + .append_labels(&session_key, &["PRIOR".to_string()]) .await; let mgr = tainting_manager().await; @@ -437,14 +477,9 @@ routes: "#; 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 mut agent = cpex_core::extensions::AgentExtension::default(); - agent.session_id = Some("sess-existing".into()); - let extensions = Extensions { - agent: Some(Arc::new(agent)), - ..Default::default() - }; + 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()) @@ -453,15 +488,27 @@ routes: // 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"); + 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)).await; + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(Arc::new(NoopDelegationInvoker) as Arc), + ) + .await; assert_eq!(decision.decision, Decision::Allow); // Only the NEW label (PII) shows up as a taint — PRIOR was already @@ -470,7 +517,7 @@ routes: assert_eq!(decision.taints[0].label, "PII"); invoker.persist_session().await; - let mut stored = session_store.load_labels("sess-existing").await; + let mut stored = session_store.load_labels(&session_key).await; stored.sort(); assert_eq!(stored, vec!["PII".to_string(), "PRIOR".to_string()]); } @@ -494,14 +541,11 @@ routes: 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 plan = DispatchCache::new() + .get_or_build(route, &cfg.plugins, &mgr) + .await; - let mut agent = cpex_core::extensions::AgentExtension::default(); - agent.session_id = Some("sess-apl-taint".into()); - let extensions = Extensions { - agent: Some(Arc::new(agent)), - ..Default::default() - }; + let (extensions, session_key) = session_ext_and_key("sess-apl-taint", "alice"); let session_store = Arc::new(MemorySessionStore::new()); let invoker = Arc::new( @@ -523,11 +567,13 @@ routes: 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.len(), + 1, + "expected one taint from `taint(...)` step" + ); assert_eq!(decision.taints[0].label, "audit"); - assert!(decision.taints[0] - .scopes - .contains(&TaintScope::Session)); + 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. @@ -546,6 +592,6 @@ routes: // And `persist_session` should pick up the label via the diff // against `initial_labels` (which was empty here). invoker.persist_session().await; - let stored = session_store.load_labels("sess-apl-taint").await; + let stored = session_store.load_labels(&session_key).await; assert_eq!(stored, vec!["audit".to_string()]); } From e97d339dd8f9bed58c4bb79ff5d1cb2ecfdd5e54 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 16:40:13 -0400 Subject: [PATCH 10/64] chore: update version Signed-off-by: Frederico Araujo --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index da736d6c..fbe11085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,10 +53,10 @@ default-members = [ ] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "Apache-2.0" -authors = ["Teryl Taylor"] +authors = ["Teryl Taylor", "Fred Araujo"] [workspace.dependencies] tokio = { version = "1", features = ["full"] } From de3b98f7154ad037dcad6ed477cb1d9f1ac4337d Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 17:07:30 -0400 Subject: [PATCH 11/64] chore: update cargo.lock Signed-off-by: Frederico Araujo --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36aebe0d..b4d53b2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,7 +52,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apl-audit-logger" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "chrono", @@ -66,7 +66,7 @@ dependencies = [ [[package]] name = "apl-cedarling" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-core", "async-trait", @@ -83,7 +83,7 @@ dependencies = [ [[package]] name = "apl-cmf" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-core", "async-trait", @@ -94,7 +94,7 @@ dependencies = [ [[package]] name = "apl-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "cpex-orchestration", @@ -109,7 +109,7 @@ dependencies = [ [[package]] name = "apl-cpex" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-cmf", "apl-core", @@ -126,7 +126,7 @@ dependencies = [ [[package]] name = "apl-delegator-biscuit" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-core", "async-trait", @@ -144,7 +144,7 @@ dependencies = [ [[package]] name = "apl-delegator-oauth" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-core", "async-trait", @@ -164,7 +164,7 @@ dependencies = [ [[package]] name = "apl-identity-jwt" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-core", "async-trait", @@ -187,7 +187,7 @@ dependencies = [ [[package]] name = "apl-pdp-cedar-direct" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-cmf", "apl-core", @@ -205,7 +205,7 @@ dependencies = [ [[package]] name = "apl-pii-scanner" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "cpex-core", @@ -776,7 +776,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "arc-swap", "async-trait", @@ -809,7 +809,7 @@ dependencies = [ [[package]] name = "cpex-ffi" -version = "0.1.0" +version = "0.2.0" dependencies = [ "apl-audit-logger", "apl-cedarling", @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "cpex-orchestration" -version = "0.1.0" +version = "0.2.0" dependencies = [ "futures", "tokio", @@ -838,7 +838,7 @@ dependencies = [ [[package]] name = "cpex-sdk" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "cpex-core", From 7bebb20f1d7b12d5d43a516b6996cf0036650d4e Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 15 Jun 2026 02:42:13 +0200 Subject: [PATCH 12/64] perf: shrink libcpex_ffi and harden cedar against small host stacks (#69) Size-first release profile and a leaner tokio floor cut the statically linked FFI footprint, and the cedar dispatch is made host-stack-agnostic so musl-based hosts (Alpine) stop hitting Cedar's stack guard. - Add [profile.release]: opt-level="z", lto=true, codegen-units=1, strip=true. libcpex_ffi.a links statically into host binaries, so this flows into their image size; a representative statically-linked consumer shrank ~21%. panic="abort" is intentionally not set (the FFI relies on catch_unwind at its #[no_mangle] boundary). No API/ABI change. - Trim the workspace tokio feature floor from ["full"] to ["rt","rt-multi-thread","sync","time","macros"] (the real union used by the crates); reqwest/hyper still union net/io where needed. Drops the unused fs/process/signal surface and the signal-hook-registry dep. - Wrap the cedar dispatch (parse + build_entities + is_authorized) in apl-pdp-cedar-direct in stacker::maybe_grow. cedar-policy aborts with 'recursion limit reached' when stacker::remaining_stack() is below its 100 KiB floor; musl's 128 KiB default thread stack trips it on inputs glibc handles fine. maybe_grow runs cedar on a fresh, large segment when the host stack is low (a no-op on glibc). stacker dedups with cedar's transitive pin, so no new crates. Adds a regression test that evaluates on a 128 KiB stack. 770 tests pass (cargo test --workspace). Signed-off-by: Frederico Araujo --- CHANGELOG.md | 22 ++++++ Cargo.lock | 23 +----- Cargo.toml | 24 +++++- crates/apl-pdp-cedar-direct/Cargo.toml | 11 +++ crates/apl-pdp-cedar-direct/src/resolver.rs | 66 +++++++++++------ .../tests/small_stack_eval.rs | 73 +++++++++++++++++++ 6 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f75689..04d020ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,28 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). config visitors (it now calls `load_config_yaml` internally so `apl:` blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped in lockstep. +- Size-first `[profile.release]`: `opt-level = "z"`, `lto = true`, + `codegen-units = 1`, `strip = true`. `libcpex_ffi.a` is linked statically + into host binaries, so this flows straight into their image size — a + representative statically-linked consumer shrank ~21%. `panic = "abort"` + is intentionally not set (the FFI relies on `catch_unwind` at its + `#[no_mangle]` boundary). No API or ABI change. +- Trimmed the workspace `tokio` feature floor from `["full"]` to + `["rt", "rt-multi-thread", "sync", "time", "macros"]` — the union of what + the crates actually use; `reqwest`/`hyper` still pull `net`/`io` where they + need them via feature unification. Drops the unused `fs`/`process`/`signal` + surface (and the `signal-hook-registry` dependency). + +### Fixed + +- Cedar evaluation no longer fails with "recursion limit reached" on hosts + that give the FFI a small thread stack (notably musl, whose default is + 128 KiB). `cedar-policy` aborts when `stacker::remaining_stack()` is below + its 100 KiB floor; the cedar dispatch in `apl-pdp-cedar-direct` is now + wrapped in `stacker::maybe_grow`, so it runs on an adequately sized stack + regardless of the host (a no-op when there is already headroom, e.g. + glibc's 8 MiB threads). Regression test exercises a real evaluation on a + 128 KiB stack. ## [0.1.0] - 2026-05-05 diff --git a/Cargo.lock b/Cargo.lock index b4d53b2b..b8c0a56b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,9 +195,11 @@ dependencies = [ "async-trait", "cedar-policy", "cpex-core", + "futures", "serde", "serde_json", "serde_yaml", + "stacker", "thiserror 2.0.18", "tokio", "tracing", @@ -1244,16 +1246,6 @@ dependencies = [ "typeid", ] -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "ff" version = "0.13.1" @@ -3546,16 +3538,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "signature" version = "2.2.0" @@ -3931,7 +3913,6 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index fbe11085..6f2b8669 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,13 @@ license = "Apache-2.0" authors = ["Teryl Taylor", "Fred Araujo"] [workspace.dependencies] -tokio = { version = "1", features = ["full"] } +# Minimal tokio feature floor for crates that take `tokio = { workspace = true }` +# without their own `features`. Production code uses time, sync, task/spawn +# (rt) and a multi-threaded runtime (cpex-ffi's `new_multi_thread`); `net` and +# `io` appear only in tests, and reqwest/hyper union those back in via feature +# 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" @@ -77,3 +83,19 @@ rmp-serde = "1" serde_bytes = "0.11" chrono = { version = "0.4", features = ["serde"] } regex = "1" + +# Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked +# statically into host binaries, so its compiled size flows straight into +# those images. The default release profile leaves symbols + debug info in +# and does no cross-crate inlining/dead-code elimination; the settings below +# 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 +# recoverable policy panic into a host-process kill. Revisit only if that +# boundary is reworked to not rely on unwinding. +[profile.release] +opt-level = "z" # optimize for size +lto = true # cross-crate inlining + dead-code elimination +codegen-units = 1 # maximize optimization (one unit, no parallel-codegen bloat) +strip = true # drop symbols + debug info from the artifact diff --git a/crates/apl-pdp-cedar-direct/Cargo.toml b/crates/apl-pdp-cedar-direct/Cargo.toml index 4072a665..15c7bcc3 100644 --- a/crates/apl-pdp-cedar-direct/Cargo.toml +++ b/crates/apl-pdp-cedar-direct/Cargo.toml @@ -44,6 +44,13 @@ apl-core = { path = "../apl-core" } # 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 = { workspace = true } serde_json = { workspace = true } @@ -61,3 +68,7 @@ apl-cmf = { path = "../apl-cmf" } apl-cpex = { path = "../apl-cpex" } cpex-core = { path = "../cpex-core" } 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 } diff --git a/crates/apl-pdp-cedar-direct/src/resolver.rs b/crates/apl-pdp-cedar-direct/src/resolver.rs index 348ed2ff..ba099132 100644 --- a/crates/apl-pdp-cedar-direct/src/resolver.rs +++ b/crates/apl-pdp-cedar-direct/src/resolver.rs @@ -41,6 +41,18 @@ 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`. @@ -207,31 +219,43 @@ impl PdpResolver for CedarDirectResolver { args: resolved_args, }; - 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(), - )?; + // 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 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 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); + let response = self + .authorizer + .is_authorized(&request, &self.policies, &entities); - Ok(translate(&response, &self.policies)) + Ok(translate(&response, &self.policies)) + }) } } diff --git a/crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs b/crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs new file mode 100644 index 00000000..92b2ddd0 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs @@ -0,0 +1,73 @@ +// Location: ./crates/apl-pdp-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 apl_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", + ); +} From 2f8210e4e5cdefba08845dcfb33e3fe26eb1adfe Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 16 Jun 2026 23:17:19 +0200 Subject: [PATCH 13/64] feat: add CEL as a PDP backend (#68) * feat: add CEL support as an APL subexpression language Signed-off-by: Frederico Araujo * fix: review fixes for cel. Signed-off-by: Teryl Taylor * fix: address remaining cel review items Config: from_config now rejects unknown keys (e.g. on_errr typo) with BuildError::ConfigShape instead of silently defaulting. Tests: add concurrent-evaluation unit test (shared resolver, one cached program), plus end-to-end integration tests for a missing expr at request time and for reading meta.entity_name through a cel: step. Docs: canonicalize the cel: { expr } step form (lib.rs, step.rs) noting the call form is also accepted; add a concrete non-boolean-result example (CEL null vs false); add CEL-vs-Cedar guidance and a synchronous-by-design note; clarify on_error: allow does not enforce PDP layering; document custom-function set ownership; trim Cargo.toml header redundant with lib.rs; refresh the PdpRouter backend list to include cel. Signed-off-by: Frederico Araujo --------- Signed-off-by: Frederico Araujo Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor --- Cargo.lock | 72 ++ Cargo.toml | 2 + crates/apl-core/src/parser.rs | 35 +- crates/apl-core/src/step.rs | 47 +- crates/apl-cpex/src/pdp_router.rs | 16 +- crates/apl-pdp-cel/Cargo.toml | 57 ++ crates/apl-pdp-cel/src/activation.rs | 370 ++++++++ crates/apl-pdp-cel/src/error.rs | 30 + crates/apl-pdp-cel/src/factory.rs | 51 ++ crates/apl-pdp-cel/src/lib.rs | 126 +++ crates/apl-pdp-cel/src/resolver.rs | 867 ++++++++++++++++++ .../apl-pdp-cel/tests/visitor_cel_config.rs | 320 +++++++ 12 files changed, 1986 insertions(+), 7 deletions(-) create mode 100644 crates/apl-pdp-cel/Cargo.toml create mode 100644 crates/apl-pdp-cel/src/activation.rs create mode 100644 crates/apl-pdp-cel/src/error.rs create mode 100644 crates/apl-pdp-cel/src/factory.rs create mode 100644 crates/apl-pdp-cel/src/lib.rs create mode 100644 crates/apl-pdp-cel/src/resolver.rs create mode 100644 crates/apl-pdp-cel/tests/visitor_cel_config.rs diff --git a/Cargo.lock b/Cargo.lock index b8c0a56b..42c3dba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,23 @@ dependencies = [ "libc", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -205,6 +222,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "apl-pdp-cel" +version = "0.2.0" +dependencies = [ + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cel", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "apl-pii-scanner" version = "0.2.0" @@ -343,6 +378,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "biscuit-auth" version = "6.0.0" @@ -615,6 +656,22 @@ dependencies = [ "zip", ] +[[package]] +name = "cel" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "pastey", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -2297,6 +2354,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2468,6 +2534,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 6f2b8669..2d87f667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/apl-cmf", "crates/apl-cpex", "crates/apl-pdp-cedar-direct", + "crates/apl-pdp-cel", "crates/apl-cedarling", "crates/apl-identity-jwt", "crates/apl-delegator-oauth", @@ -44,6 +45,7 @@ default-members = [ "crates/apl-cmf", "crates/apl-cpex", "crates/apl-pdp-cedar-direct", + "crates/apl-pdp-cel", "crates/apl-identity-jwt", "crates/apl-delegator-oauth", "crates/apl-delegator-biscuit", diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index 2a41ff86..aa45089f 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -566,10 +566,10 @@ fn parse_require_rule(line: &str) -> Result { }) } -/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(`. +/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(` / `cel:`. fn detect_step_kind(s: &str) -> Option<&'static str> { let s = s.trim_start(); - for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "sequential:", "parallel:"] { + for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "cel:", "sequential:", "parallel:"] { if s.starts_with(prefix) { return Some(prefix.trim_end_matches('(').trim_end_matches(':')); } @@ -1168,7 +1168,7 @@ fn is_known_pdp_dialect(key: &str) -> bool { let base = key.find('(').map(|i| &key[..i]).unwrap_or(key); matches!( base.trim(), - "cedar" | "cedarling" | "opa" | "authzen" | "nemo" + "cedar" | "cedarling" | "opa" | "authzen" | "nemo" | "cel" ) } @@ -3123,6 +3123,35 @@ routes: } } + #[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: + policy: + - 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_cedarling_map_form() { // `cedarling:` is its own dialect — same map shape as `cedar:` diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index dca1921e..15e49f54 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -8,8 +8,8 @@ // The DSL allows policy:/post_policy: lists to contain three kinds of // entries beyond predicate-and-action rules: // -// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)` -// with optional `on_deny:` / `on_allow:` reaction blocks +// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)`, +// `cel:(...)` with optional `on_deny:` / `on_allow:` reaction blocks // - Plugin invocations: `plugin(name)` // - Taint effects: `taint(label[, scope])` // @@ -163,6 +163,15 @@ pub enum PdpDialect { Opa, AuthZen, NeMo, + /// CEL (Common Expression Language) evaluation — `apl-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), } @@ -178,6 +187,7 @@ impl PdpDialect { "opa" => Self::Opa, "authzen" => Self::AuthZen, "nemo" => Self::NeMo, + "cel" => Self::Cel, other => Self::Custom(other.to_string()), } } @@ -504,3 +514,36 @@ pub mod delegation_bag_keys { /// when the most recent one denied. pub const GRANTED: &str = "delegation.granted"; } + +#[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("cedarling"), PdpDialect::Cedarling); + 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()) + ); + } + + #[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-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs index bbfa12fa..23cb2de0 100644 --- a/crates/apl-cpex/src/pdp_router.rs +++ b/crates/apl-cpex/src/pdp_router.rs @@ -5,8 +5,20 @@ // // `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 Cedar **and** OPA **and** -// NeMo at the same time without having to pick one at construction. +// 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** (`apl-pdp-cedar-direct`) / **cedarling** (`apl-cedarling`) +// — Cedar policy-set evaluation, in-process and via Cedarling. +// - **opa** — Open Policy Agent / Rego. +// - **authzen** — AuthZen-protocol external decision point. +// - **nemo** — NeMo reasoning backend. +// - **cel** (`apl-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 diff --git a/crates/apl-pdp-cel/Cargo.toml b/crates/apl-pdp-cel/Cargo.toml new file mode 100644 index 00000000..61f618e2 --- /dev/null +++ b/crates/apl-pdp-cel/Cargo.toml @@ -0,0 +1,57 @@ +# Location: ./crates/apl-pdp-cel/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-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 = "apl-pdp-cel" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +# 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.13", default-features = false, features = ["regex", "chrono"] } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { 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 = { path = "../apl-cmf" } +apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pdp-cel/src/activation.rs b/crates/apl-pdp-cel/src/activation.rs new file mode 100644 index 00000000..488296a9 --- /dev/null +++ b/crates/apl-pdp-cel/src/activation.rs @@ -0,0 +1,370 @@ +// Location: ./crates/apl-pdp-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/crates/apl-pdp-cel/src/error.rs b/crates/apl-pdp-cel/src/error.rs new file mode 100644 index 00000000..2b8e52b1 --- /dev/null +++ b/crates/apl-pdp-cel/src/error.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-pdp-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/crates/apl-pdp-cel/src/factory.rs b/crates/apl-pdp-cel/src/factory.rs new file mode 100644 index 00000000..caa26a45 --- /dev/null +++ b/crates/apl-pdp-cel/src/factory.rs @@ -0,0 +1,51 @@ +// Location: ./crates/apl-pdp-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: +// apl: +// 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/crates/apl-pdp-cel/src/lib.rs b/crates/apl-pdp-cel/src/lib.rs new file mode 100644 index 00000000..544a0e8a --- /dev/null +++ b/crates/apl-pdp-cel/src/lib.rs @@ -0,0 +1,126 @@ +// Location: ./crates/apl-pdp-cel/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-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; DSL §8.9). 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 / cedarling / 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/crates/apl-pdp-cel/src/resolver.rs b/crates/apl-pdp-cel/src/resolver.rs new file mode 100644 index 00000000..4115b286 --- /dev/null +++ b/crates/apl-pdp-cel/src/resolver.rs @@ -0,0 +1,867 @@ +// Location: ./crates/apl-pdp-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 apl_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) + /// }); + /// }); + /// ``` + /// + /// Function names that collide with the CEL standard library + /// (`size`, `has`, `matches`, etc.) silently shadow the built-in + /// — be deliberate. + /// + /// # 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( + "double", + |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("double(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/crates/apl-pdp-cel/tests/visitor_cel_config.rs b/crates/apl-pdp-cel/tests/visitor_cel_config.rs new file mode 100644 index 00000000..57eeb4d5 --- /dev/null +++ b/crates/apl-pdp-cel/tests/visitor_cel_config.rs @@ -0,0 +1,320 @@ +// Location: ./crates/apl-pdp-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 apl_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: + policy: + - 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())], + 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: + policy: + - 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: + policy: + - 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: + policy: + - 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: + policy: + - 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, + ); +} From c131a335a9edc280cbaea1bcc119b035afdedd35 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 18 Jun 2026 20:29:46 +0200 Subject: [PATCH 14/64] feat: improve APL ergonomics (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(apl): accept unconditional deny('reason') as a bare action `parse_rule`'s no-colon branch only recognized bare `deny` / `allow` via `try_bare_action`; the `deny('reason')` / `deny('reason', 'code')` call form was reachable only as the action half of a conditional rule (`predicate: deny('reason')`). That made it impossible to attach a reason/code to an unconditional reaction — notably `on_deny:` / `on_allow:` lists on PDP steps, which parse each item through `parse_step -> parse_step_string -> parse_rule`. Fall through to `try_parse_deny_call` after `try_bare_action` so an unconditional `deny('reason')` / `deny('reason', 'code')` parses to an `Always`-guarded `Deny`. A malformed `deny(...)` surfaces its own error rather than being misread as a predicate downstream. Add tests covering the reason-only and reason+code forms and the malformed-call error path. Signed-off-by: Frederico Araujo * feat(apl): accept `run` as an alias for `plugin` `run(name)` now invokes a named plugin everywhere `plugin(name)` does, so policies can read `- run(audit-log)` instead of `- plugin(audit-log)`: - policy-step / effect form (`parse_step_string`) — covers top-level `policy:` items and `do:` lists, and `detect_step_kind` recognizes `run(` so misrouted strings still get a clear step-kind error; - field-pipeline stage form (`args.x: "str | run(name)"`) — mirrors the step alias for symmetry. `plugin` remains the long form; both parse to the same `Step::Plugin` / `Stage::Plugin`. Errors name whichever verb the author used. Add tests for the step alias (string + compile paths), the field-stage alias, and the empty/malformed error path. Signed-off-by: Frederico Araujo * feat(apl): make the `apl:` wrapper optional APL config blocks previously had to be nested under an `apl:` key at every level (`route -> apl -> policy`, `global -> apl -> pdp`, ...). The visitor's `apl_subblock` now accepts APL terms written directly on the section when the wrapper is omitted (`route -> policy`), while the explicit `apl:` form still takes precedence. When `apl:` is absent, a synthetic block is assembled from the recognized APL keys present on the container — `policy`, `post_policy`, `args`, `result`, `pdp` — so structural keys (tool / identity / defaults / ...) are never misread. `plugins` is shape-gated: a map (the apl-override form) is included, a list (structural plugin-refs on RouteEntry / PolicyGroup) is left alone, avoiding a key-shape clash. Empty sections still return None (the "no contribution, skip" path). Applies uniformly to the global / defaults / policy-bundle / route visit sites. Add unit tests for the shape rules (wrapper precedence, flat keys, plugins map-vs-list, null/empty) and end-to-end tests for a wrapper-less route on both the allow and deny paths. Signed-off-by: Frederico Araujo * fix(apl): make the wrapper-optional feature load and fail safely Three follow-up fixes to the optional-`apl:`-wrapper work: 1. Flat `plugins:` map broke config loading at route / defaults / policy scope. The whole YAML deserializes into `CpexConfig` before any visitor runs, and `plugins` is a `Vec` there — so a wrapper-less override *map* (`plugins: { audit: { on_error: ... } }`) failed the structural parse with "invalid type: map, expected a sequence", reachable only under top-level `global:`. Give `RouteEntry.plugins` and `PolicyGroup.plugins` a `deserialize_with` that accepts either shape: a sequence stays the structural activation list; a map deserializes to an empty `Vec` (it is APL-override data, consumed by the visitor from the raw YAML). This mirrors the `apl: { plugins: {...} }` wrapper form exactly — the map never populated the structural list there either — so the flat and wrapped forms are behaviorally identical. A scalar `plugins:` now yields a shape-aware error instead of a cryptic serde message. 2. Flat `pdp:` was silently dropped at non-global scopes. Only `visit_global` builds PDPs; a `pdp:` under a default / policy-bundle / route block was folded into the policy body and discarded with no signal. Emit a `tracing::warn!` at those scopes (flat or wrapped) so the footgun is visible. 3. `plugin()` / `run()` with an empty name was accepted in field pipelines (`Stage::Plugin { name: "" }`), while the policy-step path already rejected it. Add the same empty-name guard to `parse_stage` so both paths fail with the same verb-named diagnostic. Tests: unit coverage for the map-tolerant deserializer (list, map, defaults/policies, scalar), the empty-name guard, and the pdp-warn helper; end-to-end tests that drive a flat `plugins:` map through the real `load_config_yaml` path — policy+map coexist and deny, defaults inheritance, and flat-vs-wrapped equivalence — closing the gap the isolated `apl_subblock` tests left open. Signed-off-by: Frederico Araujo * fix: lint unreferenced plugin overrides; tighten pdp warn scope. Signed-off-by: Teryl Taylor --------- Signed-off-by: Frederico Araujo Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor --- crates/apl-core/src/parser.rs | 161 ++++++++++++++++-- crates/apl-cpex/src/visitor.rs | 236 +++++++++++++++++++++++++-- crates/apl-cpex/tests/visitor_e2e.rs | 203 +++++++++++++++++++++++ crates/cpex-core/src/config.rs | 152 ++++++++++++++++- 4 files changed, 726 insertions(+), 26 deletions(-) diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index aa45089f..f99848e4 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -474,6 +474,19 @@ pub fn parse_rule(line: &str, source: &str) -> Result { 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(), + }); + } // DSL §2 default: bare predicate denies. (trimmed, vec![Effect::Deny { reason: None, code: None }]) } @@ -566,10 +579,10 @@ fn parse_require_rule(line: &str) -> Result { }) } -/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(` / `cel:`. +/// Detect `taint(...)` / `plugin(...)` / `run(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(` / `cel:`. fn detect_step_kind(s: &str) -> Option<&'static str> { let s = s.trim_start(); - for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "cel:", "sequential:", "parallel:"] { + for prefix in ["taint(", "plugin(", "run(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "cel:", "sequential:", "parallel:"] { if s.starts_with(prefix) { return Some(prefix.trim_end_matches('(').trim_end_matches(':')); } @@ -699,7 +712,7 @@ fn strip_string_literal(s: &str, rule: &str) -> Result { /// - **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` +/// - `"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` @@ -734,18 +747,25 @@ fn parse_step_string(line: &str, source: &str) -> Result { unreachable!("parse_taint always returns Stage::Taint"); } - // plugin(name) — emit as Step::Plugin. - if trimmed.starts_with("plugin(") { - let inside = extract_call_args(trimmed, "plugin") - .ok_or_else(|| ParseError::Rule { - rule: trimmed.to_string(), - msg: "malformed `plugin(...)`".into(), - })?; + // 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: "plugin name must not be empty".into(), + msg: format!("`{verb}(...)`: plugin name must not be empty"), }); } return Ok(Step::Plugin { name: name.to_string() }); @@ -1876,7 +1896,17 @@ fn parse_stage(src: &str) -> Result { a.trim(), ))) } - ("plugin", Some(a)) => Ok(Stage::Plugin { name: a.trim().to_string() }), + // `run` is an alias for `plugin` (mirrors the policy-step alias). + ("plugin" | "run", Some(a)) => { + 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() }) + } ("taint", Some(a)) => parse_taint(a, src), (other, _) => Err(bad(&format!("unknown stage `{}`", other))), @@ -2462,6 +2492,40 @@ mod tests { 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)"] { @@ -2760,6 +2824,46 @@ do: "args.card_number | str | mask(4)" } } + #[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`). + // Previously the field-stage path accepted it as + // `Stage::Plugin { name: "" }`. + 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 @@ -3071,6 +3175,39 @@ routes: } } + #[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: + policy: + - "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#" diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs index cf9eea71..349032fd 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -328,7 +328,7 @@ impl ConfigVisitor for AplConfigVisitor { // accepts maps with `policy:` / `post_policy:` / `args:` / // `result:` / `plugins:` (and inert fields it ignores), so a // shallow strip on a clone is enough. - let policy_only = strip_pdp_key(apl_block); + let policy_only = strip_pdp_key(&apl_block); let compiled = compile_policy_block_value("global.apl", &policy_only) .map_err(|e| Box::new(e) as VisitorError)?; self.state @@ -348,7 +348,8 @@ impl ConfigVisitor for AplConfigVisitor { return Ok(()); }; let source = format!("global.defaults.{}.apl", entity_type); - let compiled = compile_policy_block_value(&source, apl_block) + warn_if_pdp_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() @@ -368,7 +369,8 @@ impl ConfigVisitor for AplConfigVisitor { return Ok(()); }; let source = format!("global.policies.{}.apl", tag); - let compiled = compile_policy_block_value(&source, apl_block) + warn_if_pdp_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() @@ -397,6 +399,9 @@ impl ConfigVisitor for AplConfigVisitor { return Ok(()); } }; + if let Some(block) = &route_apl { + warn_if_pdp_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 @@ -420,7 +425,7 @@ impl ConfigVisitor for AplConfigVisitor { ) }; - for entity_name in &entity_names { + 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 @@ -449,13 +454,24 @@ impl ConfigVisitor for AplConfigVisitor { } drop(state); - if let Some(block) = route_apl { + 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); } + // 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() { @@ -638,6 +654,51 @@ fn names_of(sol: &cpex_core::config::StringOrList) -> Vec { } } +/// Warn when an APL block carries a `pdp:` declaration at a scope that +/// cannot act on it. Only [`AplConfigVisitor::visit_global`] builds PDPs +/// (they are process-global CPEX wiring); a `pdp:` 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_pdp_at_nonglobal_scope(scope: &str, apl_block: &serde_yaml::Value) { + if apl_block.get("pdp").is_some() { + tracing::warn!( + scope, + "APL visitor: `pdp:` 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)", + ); + } + } +} + /// Strip the `pdp` sub-key from an `apl:` mapping so the remainder can /// be handed to `compile_policy_block_value` (which doesn't model PDP /// declarations — those are CPEX wiring concerns). Returns a clone of @@ -667,14 +728,165 @@ fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String { on_err.to_string() } -/// Pull the `apl:` sub-block out of a section's raw YAML. Returns `None` -/// when absent or null — callers treat that as "no contribution from -/// this section" and move on. -fn apl_subblock(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> { - let block = yaml.get("apl")?; - if block.is_null() { +/// APL DSL keys recognized directly on a section (route / global / +/// defaults / policy-bundle) when the `apl:` wrapper is omitted. +/// `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`]. +const FLAT_APL_KEYS: [&str; 5] = ["policy", "post_policy", "args", "result", "pdp"]; + +/// Pull a section's APL block out of its raw YAML. +/// +/// The explicit `apl:` wrapper (`route -> apl -> policy`) takes +/// precedence. When it is absent, APL terms written directly on the +/// section (`route -> policy`) 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(block) + Some(serde_yaml::Value::Mapping(block)) + } +} + +#[cfg(test)] +mod tests { + use super::apl_subblock; + + fn yaml(s: &str) -> serde_yaml::Value { + serde_yaml::from_str(s).expect("valid yaml") + } + + #[test] + fn apl_wrapper_is_returned_as_is() { + let v = yaml("apl:\n policy:\n - \"deny\"\n"); + let block = apl_subblock(&v).expect("wrapper present"); + assert!(block.get("policy").is_some(), "wrapper block exposes policy"); + } + + #[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_policy_without_wrapper_is_collected() { + let v = yaml("tool: get_weather\npolicy:\n - \"deny\"\n"); + let block = apl_subblock(&v).expect("flat policy recognized"); + assert!(block.get("policy").is_some(), "flat policy lifted into the block"); + assert!( + block.get("tool").is_none(), + "structural keys must not leak into the apl block", + ); + } + + #[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 policy:\n - \"allow\"\npolicy:\n - \"deny\"\n"); + let block = apl_subblock(&v).expect("wrapper present"); + let policy = block + .get("policy") + .and_then(|p| p.as_sequence()) + .expect("policy sequence"); + assert_eq!(policy.len(), 1); + assert_eq!( + policy[0].as_str(), + Some("allow"), + "the explicit apl wrapper takes precedence over flat top-level keys", + ); + } + + #[test] + fn warn_if_pdp_at_nonglobal_scope_is_a_safe_noop() { + use super::warn_if_pdp_at_nonglobal_scope; + // The helper only emits a tracing event; it must never panic + // whether `pdp` is present or not. (The drop semantics are + // exercised end-to-end; here we just guard the helper's contract.) + let with_pdp = yaml("policy:\n - \"deny\"\npdp:\n - kind: cel\n"); + let without_pdp = yaml("policy:\n - \"deny\"\n"); + warn_if_pdp_at_nonglobal_scope("route", &with_pdp); + warn_if_pdp_at_nonglobal_scope("global.defaults.tool.apl", &without_pdp); + } + + #[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 policy 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( + "policy:\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()), "policy 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/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs index c3ca4cde..b4e0a5ce 100644 --- a/crates/apl-cpex/tests/visitor_e2e.rs +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -703,3 +703,206 @@ routes: msg ); } + +/// Flat form: a route may declare `policy:` 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 + policy: + - "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 `policy:` +/// 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 + policy: + - "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 `policy:` 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 + policy: + - "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: + policy: + - "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-core/src/config.rs b/crates/cpex-core/src/config.rs index 6eca89a8..a0212bd6 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -189,7 +189,7 @@ pub struct PolicyGroup { pub metadata: HashMap, /// Plugin references to activate when this group matches. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_plugin_refs")] pub plugins: Vec, /// Identity dispatch list contributed by this tag bundle. @@ -241,6 +241,49 @@ impl PluginRouteRef { } } +/// 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 + ))), + } +} + // --------------------------------------------------------------------------- // Route Entry // --------------------------------------------------------------------------- @@ -278,7 +321,7 @@ pub struct RouteEntry { pub when: Option, /// Plugin references to activate for this route. - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_plugin_refs")] pub plugins: Vec, /// Identity-resolve dispatch list for this route. **Hook-specific**: @@ -577,6 +620,16 @@ pub fn parse_config(yaml: &str) -> Result> { // --------------------------------------------------------------------------- /// 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. fn validate_config(config: &CpexConfig) -> Result<(), Box> { let mut seen_names = HashSet::new(); for plugin in &config.plugins { @@ -2003,4 +2056,99 @@ routes: ); assert!(non_matching.is_empty()); } + + // ----------------------------------------------------------------- + // `plugins:` accepts both shapes (map-tolerant deserializer) + // + // A *sequence* is the structural activation list. A *mapping* is the + // APL per-plugin override form (consumed by the APL visitor from the + // raw YAML), so it deserializes to an empty structural list here. + // Before this, a map at route/defaults/policy scope failed the whole + // `CpexConfig` parse with "invalid type: map, expected a sequence". + // + // These exercise deserialization directly (not `parse_config`, which + // also runs `validate_config`'s plugin-reference checks) because the + // bug being fixed was a *deserialize-time* failure. + // ----------------------------------------------------------------- + + 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}", + ); + } } From 67de0ae471a8193054628ee0bac13ff5450c85ab Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 00:05:49 +0200 Subject: [PATCH 15/64] feat: Valkey-backed SessionStore (cross-node / cross-restart session labels) (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Valkey session store requirements and implementation plan Signed-off-by: Frederico Araujo * feat(apl-cpex): make SessionStore fallible and fail closed on store errors U1+U2: SessionStore trait methods now return Result with a crate-local SessionStoreError. MemorySessionStore adapts (infallible -> Ok). The CMF invoker (for_request, persist_session) and route_handler propagate: - a load error fails the request closed before any decision (R5) - an append error flips the outcome to Deny with merge precedence (Allow+Err -> session.persist_failed; Deny+Err -> keep policy violation), with a distinguished alarm (R18) Sessionless traffic never touches the store. Adds fail-closed tests (AE1, AE6) plus a sessionless carve-out. Signed-off-by: Frederico Araujo * feat(apl-cpex): config-driven SessionStore selection via factory U3: Add SessionStoreFactory (mirrors PdpFactory) and a global.apl.session_store config block. The visitor builds the store during visit_global and swaps its own (now RwLock-held) session_store field before visit_route clones it into handlers — no per-request indirection. Default MemorySessionStore stays active when no block is present (R3). AplOptions gains session_store_factories; all struct-literal sites updated. Adds config-selection and unknown-kind tests (AE3, AE5). Signed-off-by: Frederico Araujo * feat(valkey): add Valkey-backed SessionStore crate + feature-gated FFI wiring U4/U5/U6: New apl-session-valkey crate (redis-rs 1.x + deadpool-redis over rustls, no openssl), excluded from default-members. ValkeySessionStore stores labels as a Redis SET keyed by taint:v1:; append is a single atomic SADD(+EXPIRE) pipeline (R16); load maps key-miss->Ok(empty) and backend/timeout->Err (R5/R15); sliding-TTL refresh on load is fail-open (R7). Config parsing enforces TLS for non-localhost endpoints (R10), commits safe timeout defaults, and warns when TTL < session lifetime (R17). Wired into cpex-ffi behind the optional 'valkey' feature; default build links no Valkey object code (R13). Signed-off-by: Frederico Araujo * test(valkey): container-backed integration tests + security hardening U7: Integration tests (testcontainers valkey, or VALKEY_TEST_URL escape hatch; #[ignore]d by default, CI-gated via REQUIRE_VALKEY_TESTS) covering cross-node union (R16/AE4), unknown->empty (R15), WRONGTYPE->fail-closed and unreachable-> fail-closed (R5), and TTL set+refresh (R7/AE2). deploy/valkey-compose.yml runs a noeviction-configured Valkey for local dev. Also hardens config.rs per security review: connection_url() percent-encodes credentials via the url crate and always reflects tls_enabled() in the scheme; rejects the tls:true + plaintext redis:// contradiction; redacts credentials from all error/log output. Adds regression tests. Signed-off-by: Frederico Araujo * docs(valkey): operator runbook for the Valkey session store U8: documents the operator-owned controls the backend depends on but cannot enforce — noeviction (R9) + monitoring, TLS/mTLS + least-privilege ACL (R10), the TTL soundness rule and refresh-failure alarm (R8/R17), the single-endpoint primary-only topology and accepted fail-closed availability tradeoff (with the sessionless carve-out), the v0 no-live-reload limitation, the alarm catalog, and local-dev setup. Signed-off-by: Frederico Araujo * chore: drop unrelated rustfmt churn swept in by cargo fmt -p Revert formatting-only changes to apl-pdp-cel / apl-pdp-cedar-direct / apl-cpex files that a crate-wide `cargo fmt` reformatted (pre-existing drift from #68) and `git add -A` swept into earlier commits. Keeps this PR focused on the Valkey session store; those files now match main exactly. Signed-off-by: Frederico Araujo * fix(valkey): wire connect_timeout, drop dead max_retries knob, harden TLS feature Addresses code-review findings: - connect_timeout_ms now bounds connection acquisition (distinct from the per-command timeout) instead of being parsed-but-ignored. - Remove the max_retries config knob: no retry/circuit-breaker exists in v0 (deferred follow-up), so the store fails closed on first error — config no longer advertises behavior the code lacks. - Forward tokio-rustls-comp through deadpool-redis explicitly so the rediss:// TLS path doesn't depend on incidental feature unification. - Add Deny+append-failure regression test: the original policy violation is preserved (not overwritten by session.persist_failed) per R18 merge rules. Signed-off-by: Frederico Araujo * style(apl-cpex): drop needless borrows in strip_non_dsl_keys (clippy) Signed-off-by: Frederico Araujo * chore: mark Valkey session store plan completed Signed-off-by: Frederico Araujo * docs: fix mermaid parse error in plan config-selection diagram ASCII '->' inside sequence-diagram message text was tokenized as an arrow. Replace with 'yields' / Unicode → (matching the requirements-doc diagrams). Signed-off-by: Frederico Araujo * docs: fix real mermaid culprit — semicolon in sequence message text Mermaid sequence message text terminates at ';', so 'yields ...; swap ...' cut the message early and the parser choked on the next line. Replace the semicolon with ', then' and drop the Arc angle brackets. (The earlier '->' fix was needed too but not the actual break.) Signed-off-by: Frederico Araujo * docs,valkey: document persistence durability + harden credential config Addresses the PR #74 review (terylt). Substantive ask — persistence/durability as a security contract: - Runbook gains a "Persistence and durability" section (§5): the label keyspace is a system-of-record, not a cache. A SADD acked then lost to a crash before fsync returns Ok(empty) on the next read (not an error), so fail-closed never trips — a silent downgrade invisible to all alarms. Documents the three fsync options, a recommended AOF baseline, and the async-replication failover interaction with the topology section. - Adds R19 to the requirements doc, peer to the R9 noeviction contract. Doc fix found while verifying: - The runbook claimed a CONFIG GET maxmemory-policy self-check that does not exist in code (the pool is lazy and never dials at config-load). Corrected §2 to present noeviction purely as operator contract; the actual self-check (noeviction + persistence) is deferred to #76. Code nits — hard-error ambiguous credential config at load, consistent with the existing tls:true + redis:// rejection: - `username` without `password` is rejected (previously dropped silently, connecting as the default user). - A full URL endpoint combined with separate `username`/`password` fields is rejected (those fields are ignored for URL endpoints). - Bare host:port credential application now applies the username when either credential is present; a lone password (default-user AUTH) stays valid. Adds regression tests for all three. apl-session-valkey unit tests green, clippy clean, cpex-ffi --features valkey builds. Signed-off-by: Frederico Araujo * fix(apl): honor flat `session_store` key + warn at non-global scope `apl_subblock` lifts APL terms written directly on a section (no `apl:` wrapper) into a synthetic block, but only for the keys in FLAT_APL_KEYS. `pdp` was listed; `session_store` was not — so a flat `global.session_store` block was silently dropped, while `global.pdp` and the `apl:`-wrapped `global.apl.session_store` both worked. Asymmetric and a silent-config footgun. - Add `session_store` to FLAT_APL_KEYS so the flat form is honored, symmetric with `pdp` and with the wrapped form. - Introduce GLOBAL_ONLY_NON_DSL_KEYS (`pdp`, `session_store`) as the single source of truth for the keys that are CPEX wiring (acted on only by visit_global) and stripped before policy compilation. strip_non_dsl_keys now iterates it. - Generalize warn_if_pdp_at_nonglobal_scope -> warn_if_global_only_key_at_nonglobal_scope so a `session_store:` written at route/default/policy-bundle scope (where it is inert) is flagged, the same way `pdp:` already is — closing the new silent-no-op the flat key would otherwise introduce. Adds tests: flat session_store is collected by apl_subblock; the renamed warning helper is a safe no-op for both keys. apl-cpex tests green, clippy clean, cpex-ffi --features valkey builds. Signed-off-by: Frederico Araujo --------- Signed-off-by: Frederico Araujo --- Cargo.lock | 788 +++++++++++++++++- Cargo.toml | 1 + crates/apl-cpex/Cargo.toml | 1 + crates/apl-cpex/src/cmf_invoker.rs | 68 +- crates/apl-cpex/src/lib.rs | 2 +- crates/apl-cpex/src/register.rs | 28 +- crates/apl-cpex/src/route_handler.rs | 154 ++-- crates/apl-cpex/src/session_store.rs | 125 ++- crates/apl-cpex/src/visitor.rs | 257 ++++-- crates/apl-cpex/tests/capability_gating.rs | 3 + crates/apl-cpex/tests/cmf_invoker_dispatch.rs | 103 ++- crates/apl-cpex/tests/config_override.rs | 11 +- crates/apl-cpex/tests/delegate_step_e2e.rs | 194 +++-- crates/apl-cpex/tests/end_to_end_route.rs | 409 ++++++++- crates/apl-cpex/tests/visitor_e2e.rs | 81 +- .../tests/visitor_pdp_config.rs | 9 +- .../apl-pdp-cel/tests/visitor_cel_config.rs | 12 +- crates/apl-session-valkey/Cargo.toml | 62 ++ crates/apl-session-valkey/src/config.rs | 388 +++++++++ crates/apl-session-valkey/src/connection.rs | 30 + crates/apl-session-valkey/src/error.rs | 31 + crates/apl-session-valkey/src/factory.rs | 45 + crates/apl-session-valkey/src/lib.rs | 44 + crates/apl-session-valkey/src/store.rs | 167 ++++ .../tests/valkey_store_integration.rs | 222 +++++ crates/cpex-ffi/Cargo.toml | 8 + crates/cpex-ffi/src/apl.rs | 15 +- deploy/valkey-compose.yml | 36 + .../valkey-session-store-requirements.md | 158 ++++ docs/operations/valkey-session-store.md | 238 ++++++ ...6-17-001-feat-valkey-session-store-plan.md | 487 +++++++++++ 31 files changed, 3772 insertions(+), 405 deletions(-) create mode 100644 crates/apl-session-valkey/Cargo.toml create mode 100644 crates/apl-session-valkey/src/config.rs create mode 100644 crates/apl-session-valkey/src/connection.rs create mode 100644 crates/apl-session-valkey/src/error.rs create mode 100644 crates/apl-session-valkey/src/factory.rs create mode 100644 crates/apl-session-valkey/src/lib.rs create mode 100644 crates/apl-session-valkey/src/store.rs create mode 100644 crates/apl-session-valkey/tests/valkey_store_integration.rs create mode 100644 deploy/valkey-compose.yml create mode 100644 docs/brainstorms/valkey-session-store-requirements.md create mode 100644 docs/operations/valkey-session-store.md create mode 100644 docs/plans/2026-06-17-001-feat-valkey-session-store-plan.md diff --git a/Cargo.lock b/Cargo.lock index 42c3dba2..1ebccc30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -254,6 +255,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "apl-session-valkey" +version = "0.2.0" +dependencies = [ + "apl-cpex", + "async-trait", + "deadpool-redis", + "redis", + "serde", + "serde_yaml", + "sha2 0.10.9", + "testcontainers", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -272,6 +292,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + [[package]] name = "arraydeque" version = "0.5.1" @@ -303,6 +329,55 @@ dependencies = [ "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "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.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -348,6 +423,58 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -401,8 +528,8 @@ dependencies = [ "nom", "p256", "pkcs8 0.9.0", - "prost", - "prost-types", + "prost 0.10.4", + "prost-types 0.10.1", "rand 0.8.6", "rand_core 0.6.4", "regex", @@ -491,6 +618,83 @@ dependencies = [ "hybrid-array", ] +[[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.18", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "chrono", + "prost 0.14.4", + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "borsh" version = "1.6.1" @@ -734,7 +938,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -877,6 +1094,7 @@ dependencies = [ "apl-identity-jwt", "apl-pdp-cedar-direct", "apl-pii-scanner", + "apl-session-valkey", "async-trait", "cpex-core", "rmp-serde", @@ -1045,6 +1263,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +dependencies = [ + "deadpool", + "redis", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1156,6 +1404,17 @@ dependencies = [ "const-random", ] +[[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" @@ -1303,6 +1562,54 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" @@ -1642,6 +1949,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1666,6 +1979,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1753,6 +2075,21 @@ 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" @@ -1769,6 +2106,19 @@ 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" @@ -1794,6 +2144,21 @@ dependencies = [ "windows-registry", ] +[[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" @@ -2199,6 +2564,12 @@ 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" @@ -2267,6 +2638,12 @@ dependencies = [ "sha2 0.11.0", ] +[[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.0" @@ -2388,6 +2765,20 @@ dependencies = [ "serde", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2414,6 +2805,15 @@ dependencies = [ "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" @@ -2440,6 +2840,17 @@ dependencies = [ "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", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2450,6 +2861,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -2511,6 +2932,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2534,6 +2961,31 @@ 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.117", +] + [[package]] name = "pastey" version = "0.2.3" @@ -2639,6 +3091,26 @@ 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.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2682,6 +3154,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2786,7 +3264,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" dependencies = [ "bytes", - "prost-derive", + "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", ] [[package]] @@ -2802,6 +3290,19 @@ 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.117", +] + [[package]] name = "prost-types" version = "0.10.1" @@ -2809,7 +3310,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d0a014229361011dc8e69c8a1ec6c2e8d0f2af7c91e3ea3f5b2170298461e68" dependencies = [ "bytes", - "prost", + "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", ] [[package]] @@ -2975,6 +3485,35 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "redis" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "ryu", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3220,6 +3759,19 @@ 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.40" @@ -3227,6 +3779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3247,6 +3800,15 @@ 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.14.1" @@ -3470,6 +4032,17 @@ 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.117", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3769,6 +4342,29 @@ 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.117", +] + +[[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.117", +] + [[package]] name = "strum" version = "0.28.0" @@ -3868,6 +4464,44 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "testcontainers" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3ac71069f20ecfa60c396316c283fbf35e6833a53dff551a31b5458da05edc" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "futures", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "ulid", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1966329d5bb3f89d33602d2db2da971fb839f9297dad16527abf4564e2ae0a6d" +dependencies = [ + "testcontainers", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4011,6 +4645,17 @@ 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.18" @@ -4056,6 +4701,46 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +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", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -4064,11 +4749,15 @@ 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]] @@ -4188,6 +4877,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4255,6 +4954,33 @@ 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" @@ -4265,8 +4991,15 @@ 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" @@ -4489,6 +5222,22 @@ 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" @@ -4498,6 +5247,12 @@ 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" @@ -4577,6 +5332,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4827,6 +5591,22 @@ 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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml-rust2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 2d87f667..01c6bc85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/apl-delegator-biscuit", "crates/apl-pii-scanner", "crates/apl-audit-logger", + "crates/apl-session-valkey", "examples/go-demo/ffi", ] diff --git a/crates/apl-cpex/Cargo.toml b/crates/apl-cpex/Cargo.toml index b0b50f4c..c859afeb 100644 --- a/crates/apl-cpex/Cargo.toml +++ b/crates/apl-cpex/Cargo.toml @@ -29,6 +29,7 @@ apl-cmf = { path = "../apl-cmf" } cpex-core = { path = "../cpex-core" } async-trait = { workspace = true } chrono = { workspace = true } +thiserror = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } tokio = { workspace = true } diff --git a/crates/apl-cpex/src/cmf_invoker.rs b/crates/apl-cpex/src/cmf_invoker.rs index 6abb3cac..b30a7fef 100644 --- a/crates/apl-cpex/src/cmf_invoker.rs +++ b/crates/apl-cpex/src/cmf_invoker.rs @@ -62,12 +62,10 @@ 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 apl_core::step::{DispatchPhase, PluginError, PluginInvocation, PluginInvoker, PluginOutcome}; use crate::dispatch_plan::RouteDispatchPlan; -use crate::session_store::SessionStore; +use crate::session_store::{SessionStore, SessionStoreError}; /// Bridges APL plugin dispatch to CMF-family CPEX hooks. /// @@ -118,19 +116,23 @@ impl CmfPluginInvoker { payload: MessagePayload, plan: Arc, session_store: Arc, - ) -> Self { + ) -> 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); + 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 - // OR no stored labels (avoid the COW clone for nothing). + // (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; + let stored = session_store.load_labels(sid).await?; if !stored.is_empty() { extensions = hydrate_labels(extensions, &stored); } @@ -138,7 +140,7 @@ impl CmfPluginInvoker { let initial_labels = snapshot_labels(&extensions); - Self { + Ok(Self { manager, extensions: Arc::new(Mutex::new(extensions)), payload: Arc::new(Mutex::new(payload)), @@ -146,7 +148,7 @@ impl CmfPluginInvoker { session_id, session_store, initial_labels, - } + }) } /// Snapshot the current payload. Call after route evaluation to @@ -218,13 +220,22 @@ impl CmfPluginInvoker { /// 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 when there - /// was no session ID. Host calls this exactly once after route - /// evaluation completes. - pub async fn persist_session(&self) { - let Some(sid) = &self.session_id else { return }; + /// 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 (R18). 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 }; + let Some(security) = current.security.as_ref() else { + return Ok(()); + }; let new_labels: Vec = security .labels .iter() @@ -233,8 +244,9 @@ impl CmfPluginInvoker { .collect(); drop(current); // release the lock before the await if !new_labels.is_empty() { - self.session_store.append_labels(sid, &new_labels).await; + self.session_store.append_labels(sid, &new_labels).await?; } + Ok(()) } } @@ -305,7 +317,10 @@ impl PluginInvoker for CmfPluginInvoker { Some(v) => (Some(v.reason), v.code), None => (None, "policy.forbidden".to_string()), }; - Decision::Deny { reason, rule_source } + Decision::Deny { + reason, + rule_source, + } } else { Decision::Allow }; @@ -319,11 +334,9 @@ impl PluginInvoker for CmfPluginInvoker { Some(modified) => { *self.payload.lock().await = modified.clone(); match invocation { - PluginInvocation::Field { .. } => { - Some(serde_json::Value::String( - modified.message.get_text_content(), - )) - } + PluginInvocation::Field { .. } => Some(serde_json::Value::String( + modified.message.get_text_content(), + )), PluginInvocation::Step { .. } => None, } } @@ -347,10 +360,8 @@ impl PluginInvoker for CmfPluginInvoker { // 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(); + let new_labels: Vec = + after_labels.difference(&before_labels).cloned().collect(); *self.extensions.lock().await = modified_ext; new_labels .into_iter() @@ -407,4 +418,3 @@ fn hydrate_labels(mut extensions: Extensions, labels: &[String]) -> Extensions { extensions.security = Some(Arc::new(security)); extensions } - diff --git a/crates/apl-cpex/src/lib.rs b/crates/apl-cpex/src/lib.rs index b5f6aaef..4afe5cf3 100644 --- a/crates/apl-cpex/src/lib.rs +++ b/crates/apl-cpex/src/lib.rs @@ -48,5 +48,5 @@ pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; pub use pdp_router::PdpRouter; pub use register::{register_apl, AplOptions}; pub use route_handler::{AplRouteHandler, Phase}; -pub use session_store::{MemorySessionStore, SessionStore}; +pub use session_store::{MemorySessionStore, SessionStore, SessionStoreError, SessionStoreFactory}; pub use visitor::AplConfigVisitor; diff --git a/crates/apl-cpex/src/register.rs b/crates/apl-cpex/src/register.rs index ab6becf2..13e03083 100644 --- a/crates/apl-cpex/src/register.rs +++ b/crates/apl-cpex/src/register.rs @@ -39,10 +39,9 @@ use cpex_core::visitor::ConfigVisitor; use apl_core::step::{PdpFactory, PdpResolver}; use crate::dispatch_plan::DispatchCache; -use crate::session_store::SessionStore; +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. @@ -75,6 +74,14 @@ pub struct AplOptions { /// `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)` @@ -96,6 +103,7 @@ impl AplOptions { session_store: Arc::new(crate::session_store::MemorySessionStore::new()), pdps: Vec::new(), pdp_factories: Vec::new(), + session_store_factories: Vec::new(), base_capabilities: None, } } @@ -142,15 +150,13 @@ impl AplOptions { /// mgr.load_config_yaml(&yaml_string)?; /// mgr.initialize().await?; /// ``` -pub fn register_apl( - mgr: &Arc, - opts: AplOptions, -) -> Arc { +pub fn register_apl(mgr: &Arc, opts: AplOptions) -> Arc { let AplOptions { dispatch_cache, session_store, pdps, pdp_factories, + session_store_factories, base_capabilities, } = opts; @@ -160,11 +166,7 @@ pub fn register_apl( // 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), - ); + let mut visitor = AplConfigVisitor::new(dispatch_cache, session_store, Arc::downgrade(mgr)); if let Some(caps) = base_capabilities { visitor = visitor.with_base_capabilities(caps); @@ -174,6 +176,10 @@ pub fn register_apl( 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 { diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index d1d1b838..2ab2ce39 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -196,16 +196,41 @@ impl AnyHookHandler for AplRouteHandler { // 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. - let invoker = Arc::new( - CmfPluginInvoker::for_request( - Arc::clone(&manager), - extensions.clone(), - msg_payload.clone(), - plan, - Arc::clone(&self.session_store), - ) - .await, - ); + // Hydration loads accumulated session labels. A store failure + // here happens *before* any policy decision, so we fail the + // request closed immediately (R5/R18, F2): 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" + ); + return Ok(Box::new(ErasedResultFields { + continue_processing: false, + modified_payload: None, + modified_extensions: None, + violation: Some(PluginViolation::new( + "session.load_failed", + "session state could not be loaded", + )), + })); + } + }; // Build the attribute bag. APL predicates read flat keys; the // BagBuilder bridges typed CPEX extensions into that namespace. @@ -319,8 +344,10 @@ impl AnyHookHandler for AplRouteHandler { invoker.apply_session_taints(&decision.taints).await; // Commit any session-scoped labels accumulated during this - // request. No-op when there was no session id. - invoker.persist_session().await; + // request. No-op when there was no session id. The result is + // folded into the decision below (R18) — 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 @@ -341,41 +368,39 @@ impl AnyHookHandler for AplRouteHandler { Phase::Pre => None, Phase::Post => Some(extract_result_from_message(&msg_payload.message)), }; - let modified_payload: Option> = - if route_payload.args != pre_args { - // 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. - let mut updated = final_payload.clone(); - write_args_back_to_message(&mut updated.message, &route_payload.args); - Some(Box::new(updated) as Box) - } else if matches!(self.phase, Phase::Post) - && pre_result - .as_ref() - .zip(route_payload.result.as_ref()) - .map(|(prev, current)| prev != current) - .unwrap_or(false) - { - // 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. - let mut updated = final_payload.clone(); - if let Some(result_value) = route_payload.result.as_ref() { - write_result_back_to_message(&mut updated.message, result_value); - } - Some(Box::new(updated) as Box) - } else if msg_payload.message.get_text_content() - != final_payload.message.get_text_content() - { - // A `policy:` plugin mutated the message directly via - // `modify_payload` (not through a field pipeline). Pass - // the invoker's view through unchanged. - Some(Box::new(final_payload) as Box) - } else { - None - }; + let modified_payload: Option> = if route_payload.args != pre_args { + // 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. + let mut updated = final_payload.clone(); + write_args_back_to_message(&mut updated.message, &route_payload.args); + Some(Box::new(updated) as Box) + } else if matches!(self.phase, Phase::Post) + && pre_result + .as_ref() + .zip(route_payload.result.as_ref()) + .map(|(prev, current)| prev != current) + .unwrap_or(false) + { + // 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. + let mut updated = final_payload.clone(); + if let Some(result_value) = route_payload.result.as_ref() { + write_result_back_to_message(&mut updated.message, result_value); + } + Some(Box::new(updated) as Box) + } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content() + { + // A `policy:` plugin mutated the message directly via + // `modify_payload` (not through a field pipeline). Pass + // the invoker's view through unchanged. + Some(Box::new(final_payload) as Box) + } else { + None + }; let modified_extensions = if extensions_changed(extensions, &final_extensions) { Some(final_extensions.cow_copy()) @@ -383,9 +408,12 @@ impl AnyHookHandler for AplRouteHandler { None }; - let (continue_processing, violation) = match decision.decision { + let (mut continue_processing, mut violation) = match decision.decision { Decision::Allow => (true, None), - Decision::Deny { reason, rule_source } => { + Decision::Deny { + reason, + rule_source, + } => { let code = if rule_source.is_empty() { "policy.deny".to_string() } else { @@ -396,6 +424,33 @@ impl AnyHookHandler for AplRouteHandler { } }; + // Append fail-closed (R18) 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; + violation = Some(PluginViolation::new( + "session.persist_failed", + "session state could not be persisted", + )); + } + } + Ok(Box::new(ErasedResultFields { continue_processing, modified_payload, @@ -566,4 +621,3 @@ fn extensions_changed(before: &Extensions, after: &Extensions) -> bool { }; security_changed || delegation_changed || raw_creds_changed } - diff --git a/crates/apl-cpex/src/session_store.rs b/crates/apl-cpex/src/session_store.rs index 54f70378..b227a8c3 100644 --- a/crates/apl-cpex/src/session_store.rs +++ b/crates/apl-cpex/src/session_store.rs @@ -27,10 +27,33 @@ // hydration/persistence into/out of `Extensions.security.labels`. use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; +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 `apl-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. /// @@ -38,19 +61,53 @@ use async_trait::async_trait; /// - `append_labels` is **monotonic** — labels added to a session never /// come back out. Removal (declassification) is a separate operation /// not covered by v0. -/// - Empty `load_labels` for an unknown `session_id` is the right -/// response — non-session traffic shouldn't fail, it just sees no -/// accumulated state. +/// - `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. Empty for - /// new or unknown sessions. - async fn load_labels(&self, session_id: &str) -> Vec; + /// 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. - async fn append_labels(&self, session_id: &str, labels: &[String]); + /// 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 @@ -75,31 +132,33 @@ impl MemorySessionStore { /// 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() + 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) -> Vec { + async fn load_labels(&self, session_id: &str) -> Result, SessionStoreError> { let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); - r.get(session_id) + Ok(r.get(session_id) .map(|s| s.iter().cloned().collect()) - .unwrap_or_default() + .unwrap_or_default()) } - async fn append_labels(&self, session_id: &str, labels: &[String]) { + async fn append_labels( + &self, + session_id: &str, + labels: &[String], + ) -> Result<(), SessionStoreError> { if labels.is_empty() { - return; + 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(()) } } @@ -111,7 +170,8 @@ mod tests { #[tokio::test] async fn load_for_unknown_session_is_empty() { let store = MemorySessionStore::new(); - assert!(store.load_labels("nonexistent").await.is_empty()); + // Unknown session is a confirmed key-miss: Ok(empty), not Err. + assert!(store.load_labels("nonexistent").await.unwrap().is_empty()); } #[tokio::test] @@ -119,8 +179,9 @@ mod tests { let store = MemorySessionStore::new(); store .append_labels("sess-1", &["PII".to_string(), "INTERNAL".to_string()]) - .await; - let mut labels = store.load_labels("sess-1").await; + .await + .unwrap(); + let mut labels = store.load_labels("sess-1").await.unwrap(); labels.sort(); assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]); } @@ -128,11 +189,15 @@ mod tests { #[tokio::test] async fn append_is_monotonic_dedupes() { let store = MemorySessionStore::new(); - store.append_labels("sess-1", &["PII".to_string()]).await; + store + .append_labels("sess-1", &["PII".to_string()]) + .await + .unwrap(); store .append_labels("sess-1", &["PII".to_string(), "PII".to_string()]) - .await; - let labels = store.load_labels("sess-1").await; + .await + .unwrap(); + let labels = store.load_labels("sess-1").await.unwrap(); assert_eq!(labels.len(), 1); assert_eq!(labels[0], "PII"); } @@ -140,10 +205,10 @@ mod tests { #[tokio::test] async fn sessions_are_isolated() { let store = MemorySessionStore::new(); - store.append_labels("a", &["X".to_string()]).await; - store.append_labels("b", &["Y".to_string()]).await; - assert_eq!(store.load_labels("a").await, vec!["X".to_string()]); - assert_eq!(store.load_labels("b").await, vec!["Y".to_string()]); + 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] @@ -151,7 +216,7 @@ mod tests { 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; - assert_eq!(c2.load_labels("sess").await, vec!["Z".to_string()]); + 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 index 349032fd..6102ebcd 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -69,7 +69,7 @@ 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; +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 @@ -130,7 +130,13 @@ struct VisitorState { pub struct AplConfigVisitor { state: RwLock, dispatch_cache: Arc, - session_store: 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>, manager: Weak, /// Baseline capabilities granted to every synthetic `AplRouteHandler` /// the visitor installs. Unioned with the per-route plugin @@ -143,6 +149,11 @@ pub struct AplConfigVisitor { /// `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 { @@ -154,10 +165,11 @@ impl AplConfigVisitor { Self { state: RwLock::new(VisitorState::default()), dispatch_cache, - session_store, + session_store: RwLock::new(session_store), manager, base_capabilities: default_base_capabilities(), pdp_factories: HashMap::new(), + session_store_factories: HashMap::new(), } } @@ -175,7 +187,54 @@ impl AplConfigVisitor { /// `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); + 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(()) } /// Replace the baseline capability set granted to every installed @@ -184,10 +243,7 @@ impl AplConfigVisitor { /// 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 { + pub fn with_base_capabilities(mut self, caps: std::collections::HashSet) -> Self { self.base_capabilities = caps; self } @@ -212,12 +268,7 @@ impl AplConfigVisitor { 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 - ) - })?; + .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 — \ @@ -322,13 +373,19 @@ impl ConfigVisitor for AplConfigVisitor { } } - // The `pdp:` sub-key isn't an APL DSL field; strip it before - // handing the block to `compile_policy_block_value` so the - // compiler doesn't see an unknown key. `compile_policy_block_value` - // accepts maps with `policy:` / `post_policy:` / `args:` / - // `result:` / `plugins:` (and inert fields it ignores), so a - // shallow strip on a clone is enough. - let policy_only = strip_pdp_key(&apl_block); + // 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)?; + } + + // 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 `policy:` / + // `post_policy:` / `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 compiled = compile_policy_block_value("global.apl", &policy_only) .map_err(|e| Box::new(e) as VisitorError)?; self.state @@ -348,7 +405,7 @@ impl ConfigVisitor for AplConfigVisitor { return Ok(()); }; let source = format!("global.defaults.{}.apl", entity_type); - warn_if_pdp_at_nonglobal_scope(&source, &apl_block); + 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 @@ -369,7 +426,7 @@ impl ConfigVisitor for AplConfigVisitor { return Ok(()); }; let source = format!("global.policies.{}.apl", tag); - warn_if_pdp_at_nonglobal_scope(&source, &apl_block); + 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 @@ -400,7 +457,7 @@ impl ConfigVisitor for AplConfigVisitor { } }; if let Some(block) = &route_apl { - warn_if_pdp_at_nonglobal_scope(&format!("routes.{entity_type}"), block); + 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 @@ -490,10 +547,9 @@ impl ConfigVisitor for AplConfigVisitor { // 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(), - ) { + 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()); } @@ -516,6 +572,16 @@ impl ConfigVisitor for AplConfigVisitor { } }; + // Snapshot the active session store (a `global.apl.session_store` + // block in `visit_global` may have swapped it). Each handler + // captures its own clone, so request-time dispatch never touches + // the visitor's lock. + let session_store = self + .session_store + .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. @@ -529,7 +595,7 @@ impl ConfigVisitor for AplConfigVisitor { Arc::clone(&route_arc), &plugin_registry, &self.dispatch_cache, - &self.session_store, + &session_store, &self.manager, Some(Arc::clone(&pdp_router_arc)), &self.base_capabilities, @@ -544,7 +610,7 @@ impl ConfigVisitor for AplConfigVisitor { route_arc, &plugin_registry, &self.dispatch_cache, - &self.session_store, + &session_store, &self.manager, Some(Arc::clone(&pdp_router_arc)), &self.base_capabilities, @@ -589,7 +655,10 @@ fn install_handler( // (`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)); + capabilities.extend(crate::dispatch_plan::route_capability_union( + &route, + plugin_registry, + )); let plugin_config = PluginConfig { name: format!( @@ -604,16 +673,15 @@ fn install_handler( 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(), - ); + let mut handler = AplRouteHandler::new( + plugin_config.clone(), + route, + phase, + Arc::clone(plugin_registry), + Arc::clone(dispatch_cache), + Arc::clone(session_store), + manager.clone(), + ); if let Some(pdp) = pdp { handler = handler.with_pdp(pdp); } @@ -654,21 +722,25 @@ fn names_of(sol: &cpex_core::config::StringOrList) -> Vec { } } -/// Warn when an APL block carries a `pdp:` declaration at a scope that +/// 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 -/// (they are process-global CPEX wiring); a `pdp:` 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_pdp_at_nonglobal_scope(scope: &str, apl_block: &serde_yaml::Value) { - if apl_block.get("pdp").is_some() { - tracing::warn!( - scope, - "APL visitor: `pdp:` is only honored under the top-level `global:` block; \ - the declaration at this scope is ignored", - ); +/// 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", + ); + } } } @@ -699,16 +771,27 @@ fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) { } } -/// Strip the `pdp` sub-key from an `apl:` mapping so the remainder can -/// be handed to `compile_policy_block_value` (which doesn't model PDP -/// declarations — those are CPEX wiring concerns). Returns a clone of -/// the mapping with `pdp` removed; the original is left intact. -fn strip_pdp_key(apl_block: &serde_yaml::Value) -> serde_yaml::Value { +/// 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; 2] = ["pdp", "session_store"]; + +/// 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(); - cloned.remove(&serde_yaml::Value::String("pdp".to_string())); + for key in GLOBAL_ONLY_NON_DSL_KEYS { + cloned.remove(serde_yaml::Value::String(key.to_string())); + } serde_yaml::Value::Mapping(cloned) } @@ -728,12 +811,24 @@ fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String { on_err.to_string() } -/// APL DSL keys recognized directly on a section (route / global / -/// defaults / policy-bundle) when the `apl:` wrapper is omitted. +/// 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`]. -const FLAT_APL_KEYS: [&str; 5] = ["policy", "post_policy", "args", "result", "pdp"]; +const FLAT_APL_KEYS: [&str; 6] = [ + "policy", + "post_policy", + "args", + "result", + "pdp", + "session_store", +]; /// Pull a section's APL block out of its raw YAML. /// @@ -815,6 +910,21 @@ mod tests { ); } + #[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. @@ -854,15 +964,18 @@ mod tests { } #[test] - fn warn_if_pdp_at_nonglobal_scope_is_a_safe_noop() { - use super::warn_if_pdp_at_nonglobal_scope; - // The helper only emits a tracing event; it must never panic - // whether `pdp` is present or not. (The drop semantics are - // exercised end-to-end; here we just guard the helper's contract.) + 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("policy:\n - \"deny\"\npdp:\n - kind: cel\n"); - let without_pdp = yaml("policy:\n - \"deny\"\n"); - warn_if_pdp_at_nonglobal_scope("route", &with_pdp); - warn_if_pdp_at_nonglobal_scope("global.defaults.tool.apl", &without_pdp); + let with_session_store = yaml("policy:\n - \"deny\"\nsession_store:\n kind: valkey\n"); + let without = yaml("policy:\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] diff --git a/crates/apl-cpex/tests/capability_gating.rs b/crates/apl-cpex/tests/capability_gating.rs index d03656e2..eaaa36e6 100644 --- a/crates/apl-cpex/tests/capability_gating.rs +++ b/crates/apl-cpex/tests/capability_gating.rs @@ -206,6 +206,7 @@ routes: session_store: Arc::new(MemorySessionStore::new()), pdps: Vec::new(), pdp_factories: Vec::new(), + session_store_factories: Vec::new(), base_capabilities: None, }, ); @@ -268,6 +269,7 @@ routes: 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()), }, ); @@ -415,6 +417,7 @@ routes: 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()), }, ); diff --git a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs index c95788b3..c6f5c0ff 100644 --- a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs +++ b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs @@ -17,8 +17,8 @@ use std::sync::Arc; use async_trait::async_trait; -use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; 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}; @@ -40,12 +40,18 @@ use apl_cpex::{CmfPluginInvoker, MemorySessionStore, RouteDispatchPlan}; /// 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 { +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() }) + Arc::new(RouteDispatchPlan { + plugins, + delegation_entries: Default::default(), + }) } // --------------------------------------------------------------------- @@ -78,7 +84,9 @@ impl HookHandler for AllowPlugin { struct AllowPluginFactory; impl PluginFactory for AllowPluginFactory { fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![( @@ -117,7 +125,9 @@ impl HookHandler for DenyPlugin { struct DenyPluginFactory; impl PluginFactory for DenyPluginFactory { fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![( @@ -173,7 +183,9 @@ impl HookHandler for ModifyPlugin { struct ModifyPluginFactory; impl PluginFactory for ModifyPluginFactory { fn create(&self, config: &PluginConfig) -> Result> { - let plugin = Arc::new(ModifyPlugin { cfg: config.clone() }); + let plugin = Arc::new(ModifyPlugin { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![( @@ -200,17 +212,11 @@ fn empty_bag() -> AttributeBag { /// 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 { +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 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"); @@ -232,10 +238,17 @@ async fn step_invocation_allow_returns_decision_allow() { plan, Arc::new(MemorySessionStore::new()), ) - .await; + .await + .expect("for_request"); let outcome = invoker - .invoke("allow-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .invoke( + "allow-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Pre, + }, + ) .await .expect("invoke"); @@ -254,15 +267,25 @@ async fn step_invocation_deny_surfaces_violation_reason_and_code() { plan, Arc::new(MemorySessionStore::new()), ) - .await; + .await + .expect("for_request"); let outcome = invoker - .invoke("deny-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .invoke( + "deny-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Pre, + }, + ) .await .expect("invoke"); match outcome.decision { - Decision::Deny { reason, rule_source } => { + Decision::Deny { + reason, + rule_source, + } => { assert_eq!(reason.as_deref(), Some("test-fixture denied this call")); assert_eq!(rule_source, "policy.forbidden"); } @@ -281,7 +304,8 @@ async fn field_invocation_modify_surfaces_modified_value_and_persists_payload() plan, Arc::new(MemorySessionStore::new()), ) - .await; + .await + .expect("for_request"); let bag = empty_bag(); let value = serde_json::Value::String("hello".to_string()); @@ -337,7 +361,8 @@ async fn current_payload_reflects_accumulated_mutations() { plan, Arc::new(MemorySessionStore::new()), ) - .await; + .await + .expect("for_request"); let bag = empty_bag(); let value = serde_json::Value::String("ignored".to_string()); @@ -355,10 +380,7 @@ async fn current_payload_reflects_accumulated_mutations() { .expect("invoke"); let final_payload = invoker.current_payload().await; - assert_eq!( - final_payload.message.get_text_content(), - "hello [MODIFIED]" - ); + assert_eq!(final_payload.message.get_text_content(), "hello [MODIFIED]"); } // --------------------------------------------------------------------- @@ -490,7 +512,10 @@ fn plan_with_narrowed_caps( entries_by_hook, }, ); - Arc::new(apl_cpex::RouteDispatchPlan { plugins, delegation_entries: Default::default() }) + Arc::new(apl_cpex::RouteDispatchPlan { + plugins, + delegation_entries: Default::default(), + }) } #[tokio::test] @@ -519,10 +544,17 @@ async fn route_override_caps_narrow_what_plugin_sees() { plan, Arc::new(MemorySessionStore::new()), ) - .await; + .await + .expect("for_request"); let outcome = invoker - .invoke("capture-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .invoke( + "capture-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Pre, + }, + ) .await .expect("invoke"); assert_eq!(outcome.decision, Decision::Allow); @@ -623,9 +655,15 @@ impl Plugin for MultiHookMarker { 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() }); + 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![ @@ -659,7 +697,8 @@ async fn multi_hook_plugin_dispatches_per_phase_via_routing_table() { plan, Arc::new(MemorySessionStore::new()), ) - .await; + .await + .expect("for_request"); // Pre phase — should hit pre handler → Allow. let pre_outcome = invoker diff --git a/crates/apl-cpex/tests/config_override.rs b/crates/apl-cpex/tests/config_override.rs index 3bfb705d..3101ad18 100644 --- a/crates/apl-cpex/tests/config_override.rs +++ b/crates/apl-cpex/tests/config_override.rs @@ -158,6 +158,7 @@ async fn build_manager(yaml: &str) -> (Arc, Arc Extensions { /// 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 { +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(); @@ -255,7 +249,11 @@ fn ext_with_subject_and_label( async fn build_setup( yaml: &str, plugins: Vec<(String, Arc, PluginConfig)>, -) -> (Arc, apl_core::CompiledConfig, Arc) { +) -> ( + Arc, + apl_core::CompiledConfig, + Arc, +) { let mgr = Arc::new(PluginManager::default()); for (_, plugin, cfg) in plugins { mgr.register_handler::(plugin, cfg) @@ -319,19 +317,22 @@ routes: 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); + 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(), @@ -440,19 +441,22 @@ routes: 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); + 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(), @@ -532,19 +536,19 @@ routes: 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); + 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(), @@ -643,19 +647,19 @@ routes: 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); + 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(), @@ -745,7 +749,11 @@ routes: "#; let (mgr, cfg, cache) = build_setup( yaml, - vec![("scoped-delegate".to_string(), Arc::clone(&plugin), plugin_cfg)], + vec![( + "scoped-delegate".to_string(), + Arc::clone(&plugin), + plugin_cfg, + )], ) .await; @@ -757,19 +765,22 @@ routes: // 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); + 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(), @@ -845,7 +856,11 @@ routes: "#; let (mgr, cfg, cache) = build_setup( yaml, - vec![("capless-delegate".to_string(), Arc::clone(&plugin), plugin_cfg)], + vec![( + "capless-delegate".to_string(), + Arc::clone(&plugin), + plugin_cfg, + )], ) .await; @@ -855,19 +870,19 @@ routes: 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); + 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(), @@ -910,4 +925,3 @@ routes: "without read_inbound_credentials, inbound token must be hidden", ); } - diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs index b06c64f1..6df3831b 100644 --- a/crates/apl-cpex/tests/end_to_end_route.rs +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -36,7 +36,10 @@ use apl_core::{ PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, }; -use apl_cpex::{CmfPluginInvoker, DispatchCache, MemorySessionStore, SessionStore}; +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 @@ -232,7 +235,8 @@ routes: plan, Arc::new(MemorySessionStore::new()), ) - .await, + .await + .expect("for_request"), ); let mut bag = AttributeBag::new(); @@ -282,7 +286,8 @@ routes: plan, Arc::new(MemorySessionStore::new()), ) - .await, + .await + .expect("for_request"), ); let mut bag = AttributeBag::new(); @@ -407,7 +412,8 @@ routes: let session_store = Arc::new(MemorySessionStore::new()); let invoker = Arc::new( CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) - .await, + .await + .expect("for_request"), ); let mut bag = AttributeBag::new(); @@ -446,8 +452,11 @@ routes: // 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; - let stored = session_store.load_labels(&session_key).await; + 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()]); } @@ -461,7 +470,8 @@ async fn session_store_hydrates_labels_at_request_start() { let session_store = Arc::new(MemorySessionStore::new()); session_store .append_labels(&session_key, &["PRIOR".to_string()]) - .await; + .await + .expect("append_labels"); let mgr = tainting_manager().await; let yaml = r#" @@ -483,7 +493,8 @@ routes: let invoker = Arc::new( CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) - .await, + .await + .expect("for_request"), ); // Hydrated labels should be observable on the invoker's extensions. @@ -516,8 +527,11 @@ routes: assert_eq!(decision.taints.len(), 1); assert_eq!(decision.taints[0].label, "PII"); - invoker.persist_session().await; - let mut stored = session_store.load_labels(&session_key).await; + 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()]); } @@ -550,7 +564,8 @@ routes: let session_store = Arc::new(MemorySessionStore::new()); let invoker = Arc::new( CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) - .await, + .await + .expect("for_request"), ); let mut bag = AttributeBag::new(); @@ -591,7 +606,375 @@ routes: // And `persist_session` should pick up the label via the diff // against `initial_labels` (which was empty here). - invoker.persist_session().await; - let stored = session_store.load_labels(&session_key).await; + 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 (U2 / R4, R5, R18; AE1, AE6). +// +// 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: + policy: + - "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 +} + +/// AE1: 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"), + ); +} + +/// AE6: 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"), + ); +} + +/// R18 merge precedence: 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: + policy: + - "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 (U3 / R2, R3; AE3, AE5). +// --------------------------------------------------------------------- + +/// 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()) + } +} + +/// AE5: 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: + policy: + - "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/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs index b4e0a5ce..2eacfd45 100644 --- a/crates/apl-cpex/tests/visitor_e2e.rs +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -76,10 +76,7 @@ impl PluginFactory for AllowGateFactory { // 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, - }) + Ok(PluginInstance { plugin, handlers }) } } @@ -89,10 +86,7 @@ impl PluginFactory for AllowGateFactory { fn hooks_for( config: &PluginConfig, plugin: Arc, -) -> Vec<( - &'static str, - Arc, -)> +) -> Vec<(&'static str, Arc)> where H: HookHandler + Plugin + 'static, { @@ -108,9 +102,8 @@ where hook_names .into_iter() .map(|name| { - let adapter: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); (name, adapter) }) .collect() @@ -134,10 +127,7 @@ impl HookHandler for DenyGate { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - PluginResult::deny(PluginViolation::new( - "policy.forbidden", - "deny-gate fired", - )) + PluginResult::deny(PluginViolation::new("policy.forbidden", "deny-gate fired")) } } @@ -148,10 +138,7 @@ impl PluginFactory for DenyGateFactory { cfg: config.clone(), }); let handlers = hooks_for(config, plugin.clone()); - Ok(PluginInstance { - plugin, - handlers, - }) + Ok(PluginInstance { plugin, handlers }) } } @@ -190,6 +177,7 @@ async fn build_manager_with_visitor(yaml: &str) -> Arc { session_store: Arc::new(MemorySessionStore::new()), pdps: Vec::new(), pdp_factories: Vec::new(), + session_store_factories: Vec::new(), base_capabilities: None, }, ); @@ -227,12 +215,7 @@ routes: ..Default::default() }; let (result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload("hi"), - ext, - None, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) .await; assert!( @@ -266,16 +249,13 @@ routes: ..Default::default() }; let (result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload("hi"), - ext, - None, - ) + .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"); + 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" @@ -316,12 +296,7 @@ routes: ..Default::default() }; let (result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload("hi"), - ext, - None, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) .await; let violation = result.violation.expect("route-level deny must fire"); @@ -357,12 +332,7 @@ routes: ..Default::default() }; let (result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload("hi"), - ext, - None, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) .await; let violation = result @@ -455,12 +425,7 @@ routes: ..Default::default() }; let (result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload("hi"), - ext, - None, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) .await; // Without APL annotations the route resolves through the legacy @@ -604,7 +569,10 @@ routes: let mgr = build_manager_with_visitor(YAML).await; let ext = Extensions { - meta: Some(Arc::new(meta_for_entity("resource", "hr://employees/E001234"))), + meta: Some(Arc::new(meta_for_entity( + "resource", + "hr://employees/E001234", + ))), ..Default::default() }; let (result, _bg) = mgr @@ -652,12 +620,7 @@ routes: // 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, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext.clone(), None) .await; assert!( tool_result.continue_processing, @@ -695,7 +658,9 @@ routes: 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 err = mgr + .load_config_yaml(YAML) + .expect_err("malformed APL block must error"); let msg = format!("{}", err); assert!( msg.contains("visitor 'apl'"), diff --git a/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs index 76ec2c11..94346396 100644 --- a/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs +++ b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs @@ -27,9 +27,7 @@ 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::extensions::{MetaExtension, SecurityExtension, SubjectExtension, SubjectType}; use cpex_core::hooks::payload::Extensions; use cpex_core::manager::PluginManager; @@ -94,6 +92,7 @@ async fn build_manager() -> Arc { // 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, }, ); @@ -157,7 +156,9 @@ async fn config_declared_cedar_pdp_denies_non_reader() { !result.continue_processing, "missing reader role should default-deny", ); - let v = result.violation.expect("deny path must surface a violation"); + 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 {}", diff --git a/crates/apl-pdp-cel/tests/visitor_cel_config.rs b/crates/apl-pdp-cel/tests/visitor_cel_config.rs index 57eeb4d5..89b72c80 100644 --- a/crates/apl-pdp-cel/tests/visitor_cel_config.rs +++ b/crates/apl-pdp-cel/tests/visitor_cel_config.rs @@ -93,15 +93,15 @@ async fn build_manager_with_yaml( // 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() - })?; + mgr.load_config_yaml(yaml) + .map_err(|e| -> Box { format!("{e}").into() })?; + mgr.initialize() + .await + .map_err(|e| -> Box { format!("{e}").into() })?; Ok(mgr) } diff --git a/crates/apl-session-valkey/Cargo.toml b/crates/apl-session-valkey/Cargo.toml new file mode 100644 index 00000000..270a7db1 --- /dev/null +++ b/crates/apl-session-valkey/Cargo.toml @@ -0,0 +1,62 @@ +# Location: ./crates/apl-session-valkey/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Fred Araujo +# +# apl-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` (mirrors apl-cedarling). 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 +# apl-identity-jwt / apl-delegator-oauth. + +[package] +name = "apl-session-valkey" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-cpex = { path = "../apl-cpex" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +sha2 = "0.10" +# 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.13", features = ["valkey"] } +testcontainers = "0.25" diff --git a/crates/apl-session-valkey/src/config.rs b/crates/apl-session-valkey/src/config.rs new file mode 100644 index 00000000..c103c474 --- /dev/null +++ b/crates/apl-session-valkey/src/config.rs @@ -0,0 +1,388 @@ +// Location: ./crates/apl-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 (R11): 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 (R9). + #[serde(default = "default_key_prefix")] + pub key_prefix: String, + + /// Sliding TTL in seconds, refreshed on load and append. `None` + /// (default) means no expiry (R7). + #[serde(default)] + pub ttl_seconds: Option, + + /// Declared maximum session-identity lifetime, used only to emit the + /// TTL-soundness warning (R17) 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 (R10); a `tls: true` + plaintext `redis://` scheme is a + /// contradiction (would connect in cleartext); the connection URL + /// must build; the TTL-soundness warning (R17) 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) — see R8" + ); + } + } + 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/crates/apl-session-valkey/src/connection.rs b/crates/apl-session-valkey/src/connection.rs new file mode 100644 index 00000000..526a4844 --- /dev/null +++ b/crates/apl-session-valkey/src/connection.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-session-valkey/src/connection.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Internal connection layer (R14): 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/crates/apl-session-valkey/src/error.rs b/crates/apl-session-valkey/src/error.rs new file mode 100644 index 00000000..38169e9d --- /dev/null +++ b/crates/apl-session-valkey/src/error.rs @@ -0,0 +1,31 @@ +// Location: ./crates/apl-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 (R10): 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/crates/apl-session-valkey/src/factory.rs b/crates/apl-session-valkey/src/factory.rs new file mode 100644 index 00000000..576918cd --- /dev/null +++ b/crates/apl-session-valkey/src/factory.rs @@ -0,0 +1,45 @@ +// Location: ./crates/apl-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/crates/apl-session-valkey/src/lib.rs b/crates/apl-session-valkey/src/lib.rs new file mode 100644 index 00000000..5449f736 --- /dev/null +++ b/crates/apl-session-valkey/src/lib.rs @@ -0,0 +1,44 @@ +// Location: ./crates/apl-session-valkey/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// apl-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 +// apl-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/crates/apl-session-valkey/src/store.rs b/crates/apl-session-valkey/src/store.rs new file mode 100644 index 00000000..9f80d4e0 --- /dev/null +++ b/crates/apl-session-valkey/src/store.rs @@ -0,0 +1,167 @@ +// Location: ./crates/apl-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 (R16). +// +// # Fail-closed mapping (R5/R15) +// +// - `SMEMBERS` on a missing key returns an empty set → `Ok(empty)` +// (unknown session, R15). It is NOT an error. +// - connection/timeout/protocol/decode failures → `Err(Backend)` so the +// caller fails the request closed. +// +// # Sliding TTL (R7) +// +// `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) (R15). Only a real + // backend failure becomes Err (R5). + 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 (R7). 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 (R16). + 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/crates/apl-session-valkey/tests/valkey_store_integration.rs b/crates/apl-session-valkey/tests/valkey_store_integration.rs new file mode 100644 index 00000000..f3e22862 --- /dev/null +++ b/crates/apl-session-valkey/tests/valkey_store_integration.rs @@ -0,0 +1,222 @@ +// Location: ./crates/apl-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 apl-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 apl_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}") +} + +/// AE4 / R16: 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()]); +} + +/// R15: 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()); +} + +/// R5: 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:?}" + ); +} + +/// R5: 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:?}" + ); +} + +/// AE2 / R7: 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/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index 8adcb8ce..d77180ff 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -33,6 +33,11 @@ apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" } # Heavy (~200 transitive deps via the Cedarling git dep); kept out of the # default `.a` and behind the `cedarling` feature. apl-cedarling = { path = "../apl-cedarling", optional = true } +# Valkey-backed SessionStore (redis client + rustls TLS stack). Optional +# and behind the `valkey` feature so the default `.a` artifact size is +# unaffected; default-members exclusion alone does NOT keep its object +# code out of a `-p cpex-ffi` build — the feature gate does. +apl-session-valkey = { path = "../apl-session-valkey", optional = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -45,6 +50,9 @@ default = [] # Opt-in Cedarling-backed identity + PDP. Build with # `cargo build -p cpex-ffi --features cedarling`. cedarling = ["dep:apl-cedarling"] +# Opt-in Valkey-backed SessionStore. Build with +# `cargo build -p cpex-ffi --features valkey`. +valkey = ["dep:apl-session-valkey"] [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs index d9300469..cfa1d07e 100644 --- a/crates/cpex-ffi/src/apl.rs +++ b/crates/cpex-ffi/src/apl.rs @@ -85,8 +85,19 @@ pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int // default. The visitor keeps a Weak (see // CpexManagerInner) that upgrades during load_config_yaml. let mut opts = apl_cpex::AplOptions::in_process(); - opts.pdp_factories = - vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; + opts.pdp_factories = vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; + + // With the `valkey` cargo feature, register the Valkey + // SessionStore factory so a `global.apl.session_store: + // { kind: valkey, ... }` config block selects it. Without the + // feature, the default in-process MemorySessionStore stays active + // and no Valkey object code is linked. + #[cfg(feature = "valkey")] + { + opts.session_store_factories = vec![Arc::new( + apl_session_valkey::ValkeySessionStoreFactory::new(), + )]; + } apl_cpex::register_apl(&inner.manager, opts); })); diff --git a/deploy/valkey-compose.yml b/deploy/valkey-compose.yml new file mode 100644 index 00000000..8aab271b --- /dev/null +++ b/deploy/valkey-compose.yml @@ -0,0 +1,36 @@ +# Location: ./deploy/valkey-compose.yml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# +# Local development / integration Valkey for the apl-session-valkey +# backend. 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). +# +# Usage: +# docker compose -f deploy/valkey-compose.yml up -d +# VALKEY_TEST_URL=redis://127.0.0.1:6379 \ +# cargo test -p apl-session-valkey --test valkey_store_integration -- --ignored +# +# 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 +# fronting endpoint. See the operator runbook. + +services: + valkey: + image: valkey/valkey:8 + command: + - valkey-server + - --maxmemory + - 256mb + - --maxmemory-policy + - noeviction + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 diff --git a/docs/brainstorms/valkey-session-store-requirements.md b/docs/brainstorms/valkey-session-store-requirements.md new file mode 100644 index 00000000..e0934c72 --- /dev/null +++ b/docs/brainstorms/valkey-session-store-requirements.md @@ -0,0 +1,158 @@ +--- +date: 2026-06-17 +topic: valkey-session-store +--- + +# Valkey-backed Session Store + +## Summary + +Add a config-selectable, Valkey-backed `SessionStore` alongside the in-process `MemorySessionStore`, so session security labels persist across application restarts and are shared across gateway nodes. It is **fail-closed**, serves **primary-only reads**, and supports an optional **sliding TTL** that is sound only when set ≥ the gateway's session-identity lifetime. To make fail-closed possible, the `SessionStore` trait gains an error channel. + +--- + +## Problem Frame + +CPEX embeds in an AI gateway that mediates A2A / MCP interactions. The only session-scoped state today is `extensions.security.labels` — a monotonic accumulation of taint/security labels per `session_id` that feed information-flow authorization decisions. The single built-in backend, `MemorySessionStore` (`crates/apl-cpex/src/session_store.rs`), holds this in a process-local `HashMap`. + +Two deployment realities break that model. When a gateway process restarts, every session's accumulated taint is lost — a session that was exposed to PII before the restart looks clean afterward. And in a multi-node deployment, a session pinned to node A carries no taint when its next request lands on node B, because the nodes share no state. In both cases the failure is silent and security-relevant: labels that should constrain a downstream operation simply aren't there. The store's own design comments already anticipate a distributed backend ("Redis or DynamoDB for distributed ones"), but none exists, and the FFI host currently hardcodes the memory store with no way to swap it. That comment predates this work; the design here is Valkey-specific (the config `kind:` is `valkey`), and broader Redis-compatibility or multi-store support is not a goal. + +--- + +## Actors + +- A1. Gateway node (CPEX-embedded): hydrates session labels at request start and appends newly-accumulated labels at request end. Multiple nodes share one store. +- A2. Operator: provisions and manages Valkey, supplies the YAML config (endpoint, TLS, auth, TTL, key prefix), and owns capacity planning and the eviction policy. +- A3. Valkey instance/endpoint: the shared, persistent KV backing the session labels. + +--- + +## Key Flows + +- F1. Cross-node / cross-restart label propagation + - **Trigger:** A session request arrives at any gateway node. + - **Actors:** A1, A3 + - **Steps:** Node loads the session's labels from the Valkey primary → evaluates policy → appends any newly-accumulated labels back (union), refreshing TTL if configured. A later request for the same session on a different node (or after a restart) loads the same unioned labels. + - **Outcome:** Accumulated taint is consistent across nodes and survives restarts. + - **Covered by:** R1, R6, R7, R15 + +- F2. Valkey store error (fail-closed) + - **Trigger:** A load or append errors (unreachable, timeout, or a reachable-but-invalid response). + - **Actors:** A1, A3 + - **Steps:** The store returns an error rather than empty labels or a dropped append. A load error fails the request closed before any decision is made. An append error also fails the request closed: `continue_processing` is computed after `persist_session` in `route_handler.rs`, so the error flips the outcome to Deny — in the Pre phase this blocks the mediated action before it is externalized (write-ahead); in the Post phase it blocks the tainted response from returning. + - **Outcome:** No silent taint loss; a store error degrades to denial, never to under-labeling. During a full outage this is self-covering — the next request's load also fails closed, so a lost append cannot cause downstream under-labeling. The residual case (append fails while reads still succeed) is alarmed. + - **Covered by:** R4, R5, R18 + +```mermaid +sequenceDiagram + participant N1 as Gateway node 1 + participant V as Valkey (primary) + participant N2 as Gateway node 2 + N1->>V: load_labels(sess) → {} + N1->>V: append_labels(sess, [PII]) (refresh TTL) + N2->>V: load_labels(sess) → {PII} + Note over N2,V: cross-node read-your-writes + N2--xV: append_labels(sess, [INTERNAL]) (unreachable) + N2-->>N2: error → request fails closed +``` + +--- + +## Requirements + +**Backend and selection** +- R1. Provide a Valkey-backed `SessionStore` implementing the existing trait surface (`load_labels` / `append_labels`), usable alongside `MemorySessionStore`. +- R2. The backend is selectable through the unified YAML config via a `SessionStore` factory/registry that mirrors the existing `PdpFactory` pattern (a `kind:`-tagged block under `global.apl`). The FFI/gateway host registers the factory so Valkey can be enabled without recompiling. +- R3. When no session-store config block is present, the default remains `MemorySessionStore` — existing deployments are unaffected. + +**Trait change and compatibility** +- R4. The `SessionStore` trait methods return a `Result` so failures propagate to callers. `MemorySessionStore` adapts (it is infallible, returning `Ok`). All call sites are updated to propagate: in the CMF (CmfPluginInvoker) invoker, `persist_session` (which calls `append_labels`) and `for_request` (which calls `load_labels` and currently returns `Self`, not `Result`) must both become fallible, and `route_handler.rs` must act on the propagated error. The trait's error type is a crate-local enum (e.g., via `thiserror`, already a workspace dependency) — not `anyhow` — so `apl-cpex` gains no new dependency. Note: this makes error-propagation part of the shared `SessionStore` contract that future bridges (apl-mcp, apl-langgraph) inherit, not a Valkey-only detail; that is intended. +- R15. Preserve monotonic union semantics across both backends: `append_labels` unions labels into the session's set, `load_labels` returns the union, and an unknown session returns empty (not an error). "Unknown session" means a positively-confirmed key-miss (the session id has no stored labels — never seen or already expired), which is distinct from a store error (R5). Within a configured TTL window, accumulation is monotonic; TTL expiry (R7) is the only sanctioned time-based removal, and explicit declassification remains out of scope. +- R16. `append_labels` MUST be implemented as a single atomic server-side set-union operation (so concurrent appends from different nodes for the same session are race-free and monotonic). Client-side read-modify-write of the label set is forbidden — it loses labels under concurrent cross-node appends. +- R18. An `append_labels` error fails the request closed, uniformly with a load error. Because `continue_processing` is computed after `persist_session` (`route_handler.rs`), the handler flips the outcome to Deny on append error: in the Pre phase this prevents the mediated action (write-ahead — taint is durably committed before the side effect is externalized); in the Post phase it blocks the tainted response. The backend MUST emit a distinguished alarm/metric on append failure, since the dangerous residual is a *selective* failure (append rejected while reads still succeed), where a subsequent load would otherwise return a stale, smaller label set. A full outage is self-covering (the next load also fails closed). + +**Failure and consistency semantics** +- R5. When Valkey is unreachable, times out, or returns an error on load or append, the store returns an error (fail-closed). It must not return empty labels or silently drop an append. A reachable-but-invalid response — a value that cannot be decoded into the expected label representation, or a partial/truncated result — is also treated as a store error (fail-closed), never as an empty or partial label set. This is distinct from the positively-confirmed key-miss of R15, which returns empty. +- R6. Reads are served from the primary only (read-your-writes consistency). No replica read-splitting. +- R19. The label keyspace is a security system-of-record, not a cache, so Valkey persistence must be configured for durability. Document as an operator runbook note (peer to the R9 `noeviction` contract) that AOF must be enabled with `appendfsync everysec` as the floor (`appendfsync always` where the threat model cannot tolerate the ~1s crash-loss window). A `SADD` acknowledged but lost to a crash before fsync makes the next read return a positively-confirmed `Ok(empty)` (R15) — *not* an error — so fail-closed (R5) never trips and the request proceeds under-labeled: a silent downgrade, and one invisible to all alarms because nothing errors. This interacts with R6: asynchronous replication means a failover can promote a replica missing the most recent un-replicated appends — the same downgrade by a different path. Like `noeviction`, the client cannot enforce this server setting; an optional best-effort `CONFIG GET appendonly`/`appendfsync` startup self-check is deferred (it would require dialing at config-load, which the lazy pool does not do today). + +**Expiry and lifecycle** +- R7. Support a configurable sliding TTL on session keys, refreshed on every load and append. Default is no expiry (TTL off). Note that refresh-on-load makes a read also issue a write (e.g., `EXPIRE`) to the primary; the design must define what happens when that refresh write fails on an otherwise-successful load (in particular under `noeviction` at capacity, see R9): a failed TTL refresh must not corrupt the load result, and the load/refresh failure semantics must be stated rather than left implicit. +- R8. Document the soundness rule: a TTL may be enabled only when set ≥ the maximum session-identity lifetime; a shorter TTL silently expires taint (downgrade-by-waiting) and is unsound. +- R17. When a TTL is configured, emit a startup WARNING (or structured audit event) if it is shorter than the configured/declared maximum session-identity lifetime. The soundness rule (R8) is otherwise enforced by nothing; a best-effort comparison catches the most common misconfiguration before it silently downgrades taint. +- R9. Provide a configurable key prefix/namespace (software requirement). Separately, document as an operator runbook note that the label keyspace must run under `maxmemory-policy noeviction`, so a full instance fails-closed on write rather than silently evicting taint — the client cannot enforce this server setting. Optionally, the backend issues a `CONFIG GET maxmemory-policy` check at startup and warns if it is not `noeviction`, making the durability property self-auditing. + +**Connection and deployment** +- R10. Connect to a single Valkey endpoint (URL or host:port) with optional password/ACL auth. TLS is **required** for any non-localhost endpoint: security labels reveal session sensitivity and must not transit a network segment in plaintext, where passive interception discloses taint state and active MITM can inject or suppress labels. The minimum auth posture for production deployments is documented in operator guidance. +- R11. The connection config specifies a single endpoint, TLS settings, and auth. Keep it minimal; if Sentinel or Cluster support is added later, the config schema is versioned at that time. (Do not pre-add unused topology fields now — that is dead config surface with no current consumer; see Scope Boundaries.) +- R12. Provide a Valkey container / compose setup for local development and integration tests. + +**Crate structure and reuse** +- R13. Ship the Valkey backend in its own crate and wire it into `cpex-ffi` as an **optional dependency behind a cargo feature** (e.g. `valkey = ["dep:apl-session-valkey"]`), mirroring `cedarling = ["dep:apl-cedarling"]`. `default-members` exclusion alone does not keep object code out of a `-p cpex-ffi` build — only feature-gating keeps the default FFI artifact (`libcpex_ffi.a`) size unaffected. Also exclude the crate from `default-members` so everyday `cargo build` stays lean. +- R14. Implement the connection/client logic (endpoint, TLS, auth, pooling, key prefix) inside the Valkey crate as an internal module. Extract a shared connection layer only when a second consumer (e.g. the OAuth token cache) is actually scheduled — at that point the interface is shaped by two real consumers (refactor-then-reuse) rather than speculatively designed for one. + +--- + +## Acceptance Examples + +- AE1. **Covers R4, R5.** Given Valkey is unreachable, when `load_labels` is called during hydration, the store returns an error and the request fails closed before any decision is made — it does not return an empty label set. +- AE6. **Covers R18.** Given a request that accumulated a new label and an `append_labels` that errors, when `persist_session` runs, the handler fails the request closed (Deny) and emits an append-failure alarm — it does not silently drop the append. +- AE2. **Covers R7.** Given a sliding TTL of 24h is configured, when a session is loaded or appended at the 23h mark, its key TTL is refreshed to 24h from that access. +- AE3. **Covers R3, R15.** Given no session-store config block, when APL is installed, `MemorySessionStore` is used and label load/append behavior is unchanged from today. +- AE4. **Covers R1, R6, R15.** Given an append on node A followed by a load on node B for the same `session_id`, node B observes the unioned labels via the shared primary. +- AE5. **Covers R2.** Given a `kind: valkey` config block, when APL is installed, the Valkey-backed store is selected as the active `SessionStore`. + +--- + +## Success Criteria + +- Session labels survive a gateway process restart and are visible across nodes sharing one Valkey endpoint. +- A Valkey outage produces explicit fail-closed errors at the call sites, never silent taint loss or under-labeling. +- Deployments that do not configure Valkey see no change to default build contents or FFI artifact size. +- `ce-plan` can implement the backend without inventing product behavior: the failure posture (fail-closed), TTL policy, and selection approach are decided here. The few genuinely technical or behavioral unknowns are enumerated explicitly under Outstanding Questions (the append-path fail-closed semantics being the one that must be resolved before planning). + +--- + +## Scope Boundaries + +- OAuth/exchanged token cache (the planned `TokenCacheControl`) — the most likely next consumer of the same Valkey connection, and the trigger for extracting a shared connection layer (R14). The token cache itself is not built here, and the connection layer is not pre-factored for it. +- Client-side Sentinel discovery and Valkey Cluster support — deferred; an infra-fronted single endpoint covers HA. +- Replica read-splitting — rejected; replication lag would silently downgrade taint. +- Local in-process fallback during outages — rejected in favor of fail-closed. +- Declassification / label removal — out of scope; the surface stays monotonic. +- Broader session KV surface (delegation hops, conversation history) beyond labels — deferred until those consumers exist. +- Sharing the CEL program cache, route/dispatch caches, or JWKS `KeyStore` through the KV store — these are per-node compute derived from config; a network store would be a pessimization, not a benefit. + +--- + +## Key Decisions + +- Config-driven selection via a `SessionStore` factory mirroring `PdpFactory`: the embedded FFI/gateway host cannot recompile to swap stores, and this reuses an established, understood pattern in the codebase. +- Fail-closed on store errors: labels drive information-flow authorization, so unavailability must degrade to denial, never to silent under-labeling. +- Append failure fails the request closed, uniformly with load failure (R18): `continue_processing` is computed after `persist_session`, so an append error flips the outcome to Deny — Pre-phase blocks the action (write-ahead), Post-phase blocks the tainted response. Chosen over best-effort+alarm because it is consistent with the never-silently-under-label thesis and the availability tradeoff already accepted; a full outage is self-covering (next load also fails closed), and the selective-failure residual is alarmed. +- `SessionStore` trait returns `Result`: the current `Vec` / `()` signatures have no error channel, which fail-closed requires. The memory store adapts trivially. +- Sliding TTL, default off: time-based expiry is time-based declassification and is sound only when the window ≥ session-identity lifetime. Gateway sessions are bounded, so a sliding TTL is available and recommended for production, but off is the safe default. +- `noeviction` + primary-only reads: eviction under memory pressure and replica replication lag are each independent silent-downgrade vectors; both are closed off so fail-closed stays honest. +- Separate crate + feature-gated FFI wiring: keeps the lean default build and FFI artifact size unchanged. The connection layer is kept internal for now and extracted only when a second consumer materializes (R14). +- Availability tradeoff (accepted): fail-closed + single-endpoint + primary-only + no local fallback means a Valkey outage or failover converts directly into correlated, fleet-wide request denial on the auth hot path. This is the deliberate price of never silently under-labeling; operators own HA via a fronting endpoint, and a latency/timeout budget bounds the blast radius (deferred to planning). + +--- + +## Dependencies / Assumptions + +- The gateway issues bounded-lifetime `session_id`s (tied to auth/conversation lifetime). This is the precondition that makes a sliding TTL sound; if it ceases to hold, TTL must be disabled. +- The operator provisions and manages Valkey, including HA via a fronting endpoint (K8s Service / VIP / proxy) and the `noeviction` memory policy. +- A Rust Valkey/Redis async client and connection pool will be selected during planning. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R2][Technical] **Config-selection seam.** "Mirror the `PdpFactory` pattern" is not a drop-in: PDPs are built *during* the config walk and threaded into handlers, but `session_store` is a single `Arc` captured into the visitor at construction (`register.rs`) before YAML parses, and the FFI `cpex_apl_install` hardcodes `in_process()` with no YAML. Planning must choose: defer store construction to the config walk, or parse the session-store block before `register_apl`. *(Raised by feasibility.)* +- [Affects R5, R6, R10][Technical] Connection/operation timeout, retry budget, and a stated latency/availability target (p99, thundering-herd control) for putting a synchronous primary round-trip on every request's critical path. *(Raised by product-lens, adversarial.)* +- [Affects R5, R16][Security] Label integrity against a compromised/writable Valkey — whether stored values need an HMAC/signature, or Valkey is accepted as a fully trusted component. Weigh the cost against the trust boundary. *(Raised by security.)* +- [Affects R1, R14][Technical] Rust client + pooling choice (e.g., `redis-rs` + `deadpool` vs `fred`), weighed against the workspace's lean-deps discipline. +- [Affects R1, R15, R16][Technical] Key/value representation and exact key schema (e.g., a Valkey SET per session keyed by prefix + `session_id`, with `SADD`/`SMEMBERS` giving the atomic union R16 requires). +- [Affects R10][Needs research] Whether the chosen client's TLS stack aligns with the workspace's existing `rustls` preference (as used by `reqwest` in `apl-identity-jwt` / `apl-delegator-oauth`). diff --git a/docs/operations/valkey-session-store.md b/docs/operations/valkey-session-store.md new file mode 100644 index 00000000..fd8f689e --- /dev/null +++ b/docs/operations/valkey-session-store.md @@ -0,0 +1,238 @@ +# Operating the Valkey Session Store + +The Valkey-backed `SessionStore` (`apl-session-valkey`) 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 +letting it proceed with missing taint. + +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. + +--- + +## 1. Enabling it + +Add a `session_store` block under `global.apl` in the unified config: + +```yaml +global: + apl: + session_store: + kind: valkey + 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 + 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 + command_timeout_ms: 500 # fail-closed hot-path budget (default) + connect_timeout_ms: 250 # default +``` + +When no `session_store` block is present, the gateway keeps its in-process +memory store and none of this applies. + +--- + +## 2. `maxmemory-policy noeviction` (required) + +Run the Valkey instance backing the label keyspace with: + +``` +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 +`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. + +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` +would happily evict live keys. Use `noeviction` unconditionally. + +**Verify and monitor:** + +``` +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`. +- 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. +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. + +--- + +## 3. TLS and least-privilege ACL + +**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 +password alone cannot connect. + +``` +# valkey.conf (sketch) +port 0 +tls-port 6379 +tls-cert-file /etc/valkey/tls/server.crt +tls-key-file /etc/valkey/tls/server.key +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`, +`EXPIRE`, and (for the self-check) `CONFIG|GET`, scoped to the key prefix: + +``` +ACL SETUSER gateway on >$STRONG_SECRET resetchannels -@all \ + ~taint:v1:* \ + +sadd +smembers +expire +config|get +``` + +- `~taint:v1:*` confines key access to the label namespace. +- 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. +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`. + +--- + +## 4. Sliding TTL and the soundness rule + +The TTL (`ttl_seconds`) is optional and **off by default**. When set, it is a +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 +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.** + +Set `max_session_lifetime_seconds` to your gateway's bound and the backend will +emit a startup warning (`alarm = "session_store_ttl_unsound"`) when the +configured TTL is shorter. This is best-effort; the operator owns the invariant. + +**TTL-refresh failures are fail-open for the read** (the labels were read +successfully). A *persistently* failing refresh, though, lets a sliding-TTL key +expire between requests and silently drop taint. Alert on +`alarm = "session_store_ttl_refresh_failed"`. + +--- + +## 5. Persistence and durability (required) + +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 — +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 +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. + +**The fsync options and their crash-loss windows:** + +| Setting | On crash | Notes | +|---------|----------|-------| +| `appendonly no` (RDB only) | Lose everything since the last snapshot (minutes) | Cache-shaped; **unsafe** for the label keyspace | +| `appendfsync everysec` | ~1s loss window | Recommended floor | +| `appendfsync always` | Effectively no loss | Per-write latency cost | + +**Recommended baseline:** AOF on with `appendfsync everysec` as the floor; use +`appendfsync always` where the threat model cannot tolerate the ~1s window. + +``` +# valkey.conf (sketch) +appendonly yes +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 +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. + +Like `noeviction`, this is operator-owned contract: the client cannot set or +enforce it, and the backend does **not** self-check it today. A best-effort +startup `CONFIG GET appendonly` / `appendfsync` warning is a deferred follow-up +(same dial-at-config-load constraint as the `noeviction` self-check in §2). + +--- + +## 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 + 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. + +- **Availability tradeoff (accepted).** Because the store is fail-closed, + single-endpoint, and has no local fallback, a Valkey outage or failover + denies **session-bearing** requests across all nodes until it recovers. This + is the deliberate price of never silently under-labeling. The + `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 + 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 + reload/restart of the gateway to take effect for newly-installed routes; the + store is selected during config load and captured by route handlers. + +--- + +## 7. Alarms to wire up + +| Signal | Meaning | Action | +|--------|---------|--------| +| `alarm = "session_store_failure"` (op=load/append) | A store load/append failed; request was denied | Investigate Valkey health/connectivity; sustained → outage | +| `alarm = "session_store_ttl_refresh_failed"` | Sliding-TTL refresh failed on an otherwise-successful read | Risk of silent key expiry; check ACL grants `+expire`, instance health | +| `alarm = "session_store_ttl_unsound"` | Configured TTL < declared session lifetime | Raise the TTL or disable it | +| `evicted_keys > 0` (Valkey `INFO`) | Eviction is dropping taint keys | Fix `maxmemory-policy` to `noeviction`; scale memory | + +--- + +## 8. Local development + +``` +docker compose -f deploy/valkey-compose.yml up -d +VALKEY_TEST_URL=redis://127.0.0.1:6379 \ + cargo test -p apl-session-valkey --test valkey_store_integration -- --ignored +``` + +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; +production must add TLS/ACL per §3 and AOF persistence per §5. diff --git a/docs/plans/2026-06-17-001-feat-valkey-session-store-plan.md b/docs/plans/2026-06-17-001-feat-valkey-session-store-plan.md new file mode 100644 index 00000000..3aac6b9b --- /dev/null +++ b/docs/plans/2026-06-17-001-feat-valkey-session-store-plan.md @@ -0,0 +1,487 @@ +--- +title: "feat: Valkey-backed SessionStore for CPEX" +type: feat +status: completed +date: 2026-06-17 +deepened: 2026-06-17 +origin: docs/brainstorms/valkey-session-store-requirements.md +--- + +# feat: Valkey-backed SessionStore for CPEX + +## Summary + +Add a config-selectable, Valkey-backed `SessionStore` alongside the in-process `MemorySessionStore`, so session security labels persist across restarts and are shared across gateway nodes. The work lands in eight dependency-ordered units: make the `SessionStore` trait fallible, propagate fail-closed semantics through the CMF invoker and route handler, add a `SessionStoreFactory` config seam, build a new feature-gated `apl-session-valkey` crate (redis-rs + deadpool-redis over rustls) with an atomic-SADD store, wire the factory through the FFI, add container-backed integration tests, and write operator docs. + +--- + +## Problem Frame + +CPEX embeds in an AI gateway mediating A2A/MCP interactions. Session security labels (`extensions.security.labels` — a monotonic taint set per `session_id` driving information-flow authorization) live only in a process-local `HashMap` (`MemorySessionStore`), so they vanish on restart and are invisible across nodes — both silent, security-relevant failures. See origin for the full frame and the resolved fail-closed/TTL/selection decisions. + +--- + +## Requirements + +Carried from origin (`docs/brainstorms/valkey-session-store-requirements.md`); R-IDs trace to it. + +- R1. Valkey-backed `SessionStore` implementing the trait surface, usable alongside `MemorySessionStore`. +- R2. Config-driven backend selection via a `SessionStoreFactory` (mirrors `PdpFactory`), `kind:`-tagged block under `global.apl`; host registers the factory. +- R3. Default remains `MemorySessionStore` when no session-store config block is present. +- R4. `SessionStore` trait methods return `Result`; `MemorySessionStore` adapts; all call sites (`for_request`, `persist_session`, `route_handler.rs`) propagate. Crate-local `thiserror` error type, not `anyhow`. +- R5. Unreachable/timeout/error, **and** reachable-but-undecodable/partial responses → store error (fail-closed). Distinct from a positively-confirmed key-miss (R15). +- R6. Primary-only reads (read-your-writes); no replica read-splitting. +- R7. Configurable sliding TTL refreshed on load and append; default off. Refresh-on-load is a write; define load/refresh-failure semantics. +- R8. Document the TTL soundness rule (TTL ≥ max session-identity lifetime). +- R9. Configurable key prefix/namespace (software); `noeviction` as operator runbook note + optional startup `CONFIG GET maxmemory-policy` self-check. +- R10. Single endpoint with optional password/ACL auth; **TLS required for non-localhost**. +- R11. Minimal connection config (single endpoint, TLS, auth); no pre-added Sentinel/Cluster fields. +- R12. Valkey container/compose setup for local dev and integration tests. +- R13. Own crate, feature-gated into `cpex-ffi` (`valkey = ["dep:apl-session-valkey"]`) **and** excluded from `default-members`. +- R14. Connection/client logic internal to the crate; no pre-factored shared layer for the deferred token cache. +- R15. Monotonic union semantics; unknown session (confirmed key-miss) returns empty, not error; monotonic within the TTL window. +- R16. `append_labels` is a single atomic server-side set-union; no client-side read-modify-write. +- R17. Startup WARNING when configured TTL < declared max session-identity lifetime. +- R18. Append error fails the request closed uniformly with load error (via `continue_processing` computed after `persist_session`); distinguished append-failure alarm on the selective-failure residual. + +**Origin actors:** A1 (gateway node), A2 (operator), A3 (Valkey endpoint). +**Origin flows:** F1 (cross-node/cross-restart propagation), F2 (store error → fail-closed). +**Origin acceptance examples:** AE1 (R4,R5 load fail-closed), AE2 (R7 TTL refresh), AE3 (R3,R15 default unchanged), AE4 (R1,R6,R15 cross-node union), AE5 (R2 config selection), AE6 (R18 append fail-closed + alarm). + +--- + +## Scope Boundaries + +- Sentinel/Cluster support, replica read-splitting, local in-process fallback, declassification/label removal, broader session KV surface, sharing per-node compute caches through the store — all out of scope (see origin). +- OAuth/exchanged token cache (`TokenCacheControl`) — the eventual second consumer and the trigger to extract a shared connection layer; not built here, connection layer not pre-factored. +- Application-level HMAC/signing of stored labels — out of scope for v0 (see Key Technical Decisions); the trust model is TLS/ACL/`noeviction`/network isolation. + +### Deferred to Follow-Up Work + +- HMAC-of-stored-values: revisit only if a deployment's threat model includes a Valkey writable by a party who cannot reach the gateway's signing key. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `crates/apl-cpex/src/session_store.rs` — `SessionStore` trait (`#[async_trait]`, `load_labels -> Vec`, `append_labels -> ()`), `MemorySessionStore`, unit-test shape (sort-before-assert, `Arc`). +- `crates/apl-cpex/src/cmf_invoker.rs` — `for_request` (`-> Self`, calls `load_labels` during hydration) and `persist_session` (`-> ()`, calls `append_labels`). The only two trait call sites. +- `crates/apl-cpex/src/route_handler.rs` — builds invoker via `for_request().await`, calls `persist_session().await` after evaluation; `continue_processing` derived from `decision.decision` **after** persist. Handler returns `Result<_, Box>`. +- `crates/apl-core/src/step.rs` — `PdpFactory` trait (`kind()` + `build(&serde_yaml::Value) -> Result, Box>`): the model to mirror. +- `crates/apl-cpex/src/visitor.rs` — `visit_global` walks `global.apl.pdp[]` and consults factories during the config walk; `build_pdp_from_config`. Session store, by contrast, is captured at visitor construction (`register.rs`), not built during the walk — the seam to bridge. +- `crates/apl-cpex/src/register.rs` — `AplOptions { session_store, pdp_factories, ... }`, `register_apl`; `AplOptions::in_process()` defaults to `MemorySessionStore`. +- `crates/cpex-ffi/src/apl.rs` — `cpex_apl_install` hardcodes `in_process()`, registers `pdp_factories`, receives no YAML (config arrives later via `cpex_load_config`). +- `crates/cpex-ffi/Cargo.toml` + root `Cargo.toml` — `apl-cedarling` optional-dep + `cedarling = ["dep:apl-cedarling"]` feature, plus `default-members` exclusion: the exact pattern R13 mirrors. +- `crates/apl-pdp-cel/` — reference leaf-crate layout (`Cargo.toml`, `lib.rs`, `factory.rs`, `error.rs`, `resolver.rs`, `tests/visitor_cel_config.rs`), `pub const KIND`, `thiserror` `BuildError`. +- `crates/apl-pdp-cel/tests/visitor_cel_config.rs` — canonical config-driven-backend integration-test harness (register_apl → load_config_yaml → initialize → invoke_named). +- `crates/apl-delegator-oauth/Cargo.toml`, `crates/apl-identity-jwt/Cargo.toml` — `reqwest = { default-features = false, features = ["json","rustls-tls"] }`: the rustls-over-native-tls discipline. +- `crates/cpex-core/src/error.rs` — `PluginError` (`Config { message }`, `Denied { violation }`, …) and host propagation. + +### Institutional Learnings + +- No `docs/solutions/` knowledge base exists; the origin requirements doc is the authoritative spec. After this lands, capture the trait-change and client decisions somewhere durable. +- PR #67 lesson: external-dependency tests silently passed as no-ops when the native module was absent. Integration tests here must skip **loudly** and be **CI-enforced** (env gate) so they cannot green-wash zero coverage. +- File-header convention (Location/Copyright/SPDX/Authors) is `make`-checked and mandatory on every new file. + +### External References + +- redis-rs (`redis` 1.x) docs.rs — `aio`/`tokio-comp`/`tokio-rustls-comp` features (tokio-rustls 0.26 + rustls 0.23, no native-tls), `AsyncConnectionConfig` timeouts, `ConnectionManager` reconnect/backoff, `pipe().atomic()` for MULTI/EXEC, `RedisError` predicates (`is_timeout`/`is_io_error`/`is_connection_dropped`, `ErrorKind::Parse`/`UnexpectedReturnType`). +- `deadpool-redis` 0.23 — external pool forwarding `tokio-rustls-comp`. +- Valkey docs — Transactions (MULTI/EXEC isolation), EXPIRE (refresh updates TTL; SADD leaves TTL untouched; overwrite commands clear it), Eviction (`noeviction`, `evicted_keys`), Replication (async → stale-read fail-open), ACL (least-privilege `~taint:v1:* +sadd +smembers +expire +config|get`), TLS/mTLS. +- AWS Builders' Library — timeouts/retries/backoff-with-jitter, circuit breaker, retry-storm avoidance (token-bucket budget). +- `testcontainers-modules` valkey feature. + +--- + +## Key Technical Decisions + +- **Config seam = factory + in-visitor store swap.** Add a `SessionStoreFactory` trait mirroring `PdpFactory`; the visitor consults it on a `global.apl.session_store` block during `visit_global`. Because `visit_global` runs before `install_handler` in the config walk, it can swap the visitor's own `session_store` field before any handler captures it — no per-request indirection, no handler-signature change, no new FFI entry point. (`ArcSwap` is reserved for a future live-reload need, not v0; see U3.) Rationale: truest fit for R2/AE5 and host-agnostic; the FFI-boundary alternative would not satisfy "selected via the walked YAML." +- **Client = `redis` (redis-rs) 1.x + `deadpool-redis` 0.23, `default-features=false`, `tokio-rustls-comp`.** Leanest deps, no forced crypto provider, tokio-minimal, CI-tested against Valkey 7+, `pipe().atomic()` = SADD+EXPIRE in one MULTI/EXEC. fred is the heavier batteries-included alternative; rejected on dep weight + older release cadence. +- **Key schema = `taint:v1:` SET.** Full-width SHA-256 keeps the Valkey keyspace itself collision-free and removes raw ids from the keyspace (charset-safe). It does **not** restore entropy lost upstream: `session_id` is already a 64-bit truncated digest from `session_resolver.rs` (`short_hash`), so two subjects colliding there already share one logical session and will deterministically share one Valkey key — closing that upstream collision (a wider `short_hash`) is out of scope here (cross-referenced as a residual). `SADD` for atomic union, `SMEMBERS` for load. +- **No application-level HMAC in v0.** Signing protects against altered labels but not deletion/under-labeling (the primary risk) and only helps if the attacker can't reach the gateway's signing key. Trust Valkey within the boundary (TLS/mTLS + least-privilege ACL + `noeviction` + network isolation); document the residual. +- **Fail-closed error mapping:** `Ok(empty set)` → empty labels (unknown session, R15); `Err` where `is_timeout|is_io_error|is_connection_dropped` or `ErrorKind::Parse|UnexpectedReturnType` → store error (R5). Empty-set is never an error (SMEMBERS on a missing key returns `[]`). +- **Append fail-closed mechanism:** `persist_session` returns `Result`; `route_handler.rs` converts an append `Err` into `continue_processing = false` + a `PluginViolation` (e.g. `session.persist_failed`) **before** building `ErasedResultFields`, since that struct is constructed after persist. Emit a distinguished append-failure metric/log (R18). +- **TTL via atomic pipeline:** `pipe().atomic().sadd(...).ignore().expire(...).ignore()` for append+refresh in one round trip. On load, a separate `EXPIRE` refresh is **fail-open for the current request**: the successfully-read labels are returned `Ok`, and a refresh failure is alarmed (not failed-closed) — the read itself succeeded. Cross-request consequence (a persistently-failing refresh lets a sliding-TTL key expire → a later load returns `Ok(empty)`, silently dropping taint) is covered by the alarm and documented in U8; this is the deliberate trade for not denying a request whose labels were read correctly. +- **Timeouts/retries (committed defaults, configurable):** ship concrete defaults so behavior and tests are deterministic — connect timeout **250ms**, per-command response timeout **500ms** (never `None`), **1** jittered retry behind a token-bucket budget at a single layer, circuit-breaker opens after **N consecutive failures** (default e.g. 5) → immediate fail-closed. Operators tune these from the committed baseline; they are not left undecided. + +--- + +## Open Questions + +### Resolved During Planning + +- Config-selection seam (origin deferred): resolved → factory + `arc-swap` late-bound handle (see Key Technical Decisions, U3). +- Client + pooling choice (origin deferred): resolved → redis-rs + deadpool-redis, rustls. +- Key/value representation (origin deferred): resolved → prefixed SHA-256 SET, SADD/SMEMBERS, atomic SADD+EXPIRE. +- rustls alignment (origin deferred): resolved → `tokio-rustls-comp` (tokio-rustls 0.26 + rustls 0.23), no native-tls/openssl. +- Label integrity/HMAC (origin deferred): resolved → no HMAC in v0; trust-boundary controls instead. +- Timeout/retry budget (origin deferred): resolved as configurable defaults (see Key Technical Decisions); exact production values are operator-tuned. + +### Deferred to Implementation + +- **Config live-reload behavior** — if `load_config_yaml` re-walks the *same* `AplConfigVisitor` on reload, the v0 in-visitor swap re-targets the field but already-installed handlers captured the prior `Arc` by value, and in-flight requests hold the prior store. v0 does **not** support session-store live-reload; if/when required, this is the one case that justifies the `ArcSwap` handle (U3). State the v0 limitation in U8. +- Whether the `CONFIG GET maxmemory-policy` self-check and the TTL-vs-lifetime warning run at factory `build()` time or at first connection — depends on when a live connection is first available. +- Exact `PluginError`/`PluginViolation` variant names and `SessionStoreError` variants — finalize against the surrounding code. The committed timeout/retry defaults (250ms/500ms/1-retry/5-failure-breaker) are tuning baselines, not open questions. +- **R14 verification** — "no public connection-layer API" is enforced by inspection (the `connection` module stays non-`pub` in `lib.rs`); there is no runtime test for it. Flag for reviewer check rather than a test assertion. + +--- + +## Output Structure + + crates/apl-session-valkey/ + Cargo.toml + src/ + lib.rs # module docs, pub use surface, pub const KIND + config.rs # YAML config parse: endpoint, TLS, auth, prefix, TTL, timeouts + error.rs # thiserror BuildError (config/connection construction) + connection.rs # internal: redis-rs + deadpool-redis pool, rustls, timeouts, reconnect + store.rs # ValkeySessionStore: SADD/SMEMBERS, atomic pipeline, key schema, error mapping + factory.rs # ValkeySessionStoreFactory: kind()="valkey", build(&serde_yaml::Value) + tests/ + valkey_store_integration.rs # testcontainers valkey: union/TTL/noeviction/ACL/fail-closed + docs/ + operations/valkey-session-store.md # operator runbook (R8/R9/R10) + deploy/ + valkey-compose.yml # local dev / integration container + +--- + +## 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.* + +Config-selection seam — how a `kind: valkey` block becomes the active store without changing the FFI's no-YAML install contract: + +```mermaid +sequenceDiagram + participant Host as Host / cpex-ffi + participant Reg as register_apl + participant Vis as AplConfigVisitor + participant Walk as load_config_yaml (visit_global) + participant H as AplRouteHandler + + Host->>Reg: AplOptions { session_store_factories:[valkey], default=Memory } + Reg->>Vis: new(.. session_store field = Memory ..) + Note over Host,Walk: later — config arrives via cpex_load_config + Walk->>Vis: visit_global sees global.apl.session_store { kind: valkey, .. } + Vis->>Vis: factory.build(cfg) yields a SessionStore Arc, then swaps visitor.session_store + Walk->>H: visit_route → install_handler clones the (already-swapped) Arc by value + Note over H: request time — handler uses the config-selected (valkey) store +``` + +Fail-closed wiring across load (pre-decision) and append (post-decision): + +```mermaid +flowchart TD + A[for_request: load_labels] -->|Err| D[fail closed before decision] + A -->|Ok labels| B[evaluate policy -> decision] + B --> C[persist_session: append_labels] + C -->|Err| E[continue_processing=false + violation + alarm] + C -->|Ok| F[continue_processing from decision] + E --> G[ErasedResultFields] + F --> G +``` + +--- + +## Implementation Units + +- U1. **Make `SessionStore` fallible** + +**Goal:** Change the trait to return `Result` with a crate-local error type; adapt `MemorySessionStore` and its tests. + +**Requirements:** R4, R15 + +**Dependencies:** None + +**Files:** +- Modify: `crates/apl-cpex/src/session_store.rs` +- Create: `crates/apl-cpex/src/session_store_error.rs` (or an inline `SessionStoreError` in `session_store.rs`) +- Test: `crates/apl-cpex/src/session_store.rs` (existing `#[cfg(test)] mod tests`) + +**Approach:** +- Define `SessionStoreError` via `thiserror` (workspace dep), variants covering connection/timeout, decode/protocol, and a generic backend message. Keep it string-friendly so non-CMF bridges can map it. +- Change `load_labels -> Result, SessionStoreError>` and `append_labels -> Result<(), SessionStoreError>`. Preserve the R15 contract in the doc comment: unknown session → `Ok(empty)`, never `Err`. +- `MemorySessionStore` returns `Ok(...)`; update the four unit tests to `.await.unwrap()`. + +**Patterns to follow:** `crates/apl-pdp-cel/src/error.rs` (thiserror `BuildError`); existing async-trait usage in `session_store.rs`. + +**Test scenarios:** +- Happy path: `append_then_load` round-trips, returns `Ok`. Covers AE3. +- Edge case: unknown session → `Ok(empty)`, not `Err`. Covers AE3. +- Edge case: monotonic dedupe across appends still holds under the `Result` signature. + +**Verification:** `apl-cpex` compiles; memory-store unit tests pass; trait doc states the unknown-session = `Ok(empty)` invariant. + +--- + +- U2. **Propagate fail-closed through CMF invoker + route handler** + +**Goal:** Thread the `Result` through `for_request`/`persist_session` and make `route_handler.rs` fail the request closed on load (pre-decision) and append (post-decision) errors, with a distinguished append-failure alarm. + +**Requirements:** R4, R5, R18; F2; AE1, AE6 + +**Dependencies:** U1 + +**Files:** +- Modify: `crates/apl-cpex/src/cmf_invoker.rs` (`for_request`, `persist_session`) +- Modify: `crates/apl-cpex/src/route_handler.rs` (`invoke`) +- Create: a test-double `SessionStore` (erroring + call-recording) — none exists today (all test sites use `MemorySessionStore`); place under `crates/apl-cpex/src/session_store.rs` `#[cfg(test)]` or a shared `tests/support/` module. +- Test: `crates/apl-cpex/tests/cmf_invoker_dispatch.rs`, `crates/apl-cpex/tests/end_to_end_route.rs` + +**Approach:** +- `for_request` → `Result`; a load error `?`-propagates as `Box` so the request fails closed before any decision (load runs pre-evaluation). Note: `load_labels` only runs when `session_id` is `Some` — sessionless/anonymous traffic has no state to load and is unaffected by a store outage (see blast-radius note in U8/R5). +- `persist_session` → `Result<(), …>`. In `invoke`, capture its `Result` (today it is a bare `.await;` discarding `()`), then apply this explicit **merge precedence** when building the `(continue_processing, violation)` tuple that feeds `ErasedResultFields` (a single `Option` slot): + - decision = **Allow** + append `Ok` → `(true, None)` (unchanged). + - decision = **Allow** + append `Err` → flip to `(false, Some(session.persist_failed violation))`. + - decision = **Deny** + append `Err` → keep the original policy violation (preserve attribution); `continue_processing` is already `false`. The append failure surfaces **only** as the distinguished alarm/metric, not in the violation slot. +- Emit a distinguished `tracing` error + metric on append failure regardless of decision (the selective-failure residual). Note `persist_session` no-ops when no new labels were added, so the append-fail path is only reachable on label-producing requests. + +**Execution note:** Start with a failing integration test asserting append-error → Deny (AE6), then wire the handler. + +**Patterns to follow:** `PluginError::Config`/`Denied` construction in `route_handler.rs`; existing `end_to_end_route.rs` harness. + +**Test scenarios:** +- Error path: `load_labels` returns `Err` during hydration → `invoke` fails closed, no decision computed. Covers AE1. +- Error path: decision Allow + `append_labels` `Err` → `continue_processing=false` + `session.persist_failed` violation + alarm. Covers AE6. +- Error path: decision Deny + `append_labels` `Err` → original policy violation preserved, append failure only alarmed (merge precedence). +- Happy path: both `Ok` → behavior identical to today (Allow still allows, Deny still denies). Covers AE3. +- Integration: a fake store erroring only on append (reads succeed) → request denied and alarm fired (selective-failure residual). +- Edge case: sessionless request (no `session_id`) during a simulated store outage → unaffected (no load, no append). + +**Verification:** New tests pass; a store error never yields an Allow with dropped labels; existing route tests still pass with the memory store. + +--- + +- U3. **`SessionStoreFactory` trait + config-selection seam** + +**Goal:** Add config-driven backend selection mirroring `PdpFactory`, with a late-bound active-store handle so the config walk can install the selected store. + +**Requirements:** R2, R3; AE3, AE5 + +**Dependencies:** U1 + +**Files:** +- Modify: `crates/apl-core/src/step.rs` (or a new `apl-core` module) — define `SessionStoreFactory` +- Modify: `crates/apl-cpex/src/register.rs` (`AplOptions.session_store_factories` + the exhaustive `AplOptions { .. }` destructure at the top of `register_apl`) +- Modify: `crates/apl-cpex/src/visitor.rs` (`visit_global` consults `global.apl.session_store`; swap the visitor's own store field) +- Modify the exhaustive `AplOptions { .. }` struct-literal sites that will otherwise fail to compile (no `..Default::default()` today): `crates/apl-cpex/tests/config_override.rs`, `crates/apl-cpex/tests/visitor_e2e.rs`, `crates/apl-cpex/tests/capability_gating.rs` (3 sites), `crates/apl-pdp-cel/tests/visitor_cel_config.rs`, `crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs`. Consider adding `Default`/a builder for `AplOptions` so future field additions don't break literals. +- Test: `crates/apl-cpex/tests/config_override.rs` (or a new `tests/session_store_config.rs`) + +**Approach:** +- `SessionStoreFactory`: `kind() -> &str`, `build(&serde_yaml::Value) -> Result, Box>` — exact shape of `PdpFactory`. +- `AplOptions.session_store_factories: Vec>`, registered into the visitor like `pdp_factories`. Empty list → memory default, so existing `AplOptions::in_process()` callers are unaffected. +- **Primary mechanism (simplest correct):** `visit_global` runs strictly before `visit_route`/`install_handler` during the config walk (confirmed: `visitor.rs:307` vs `381+`, ordering in `manager.rs`). So `visit_global` parses the optional `global.apl.session_store { kind, ... }`, looks up the factory, builds the store, and **swaps the visitor's own `session_store` field**; `install_handler` then clones the already-selected `Arc` into each handler by value, exactly as today. No per-request indirection and no handler-signature change. +- **`ArcSwap` is reserved for live config-reload only** — re-targeting *already-installed* handlers after a swap. v0 does not support session-store live-reload (see Deferred to Implementation); if added later, use `Arc>>` (the inner `Arc` must be sized) on a shared handle the handler reads. Do not pay the per-request `ArcSwap` load cost in v0. +- No block present → default memory store (R3). Unknown `kind` / malformed block → `VisitorError` failing `load_config_yaml`. + +**Technical design:** see High-Level Technical Design sequence diagram (directional). + +**Patterns to follow:** `build_pdp_from_config` + `register_pdp_factory` in `visitor.rs`; `PdpFactory` in `apl-core/src/step.rs`. + +**Test scenarios:** +- Happy path: no `session_store` block → memory store active; load/append unchanged. Covers AE3. +- Happy path: a `kind: ` block → the fake store is selected and observed at request time. Covers AE5 (structure; Valkey-specific selection verified in U7). +- Error path: unknown `kind` → config load fails with a clear error. +- Edge case: handle defaults to memory before the walk; after the walk the swapped store is visible to a freshly installed handler. + +**Verification:** A config-selected fake store receives `append_labels`/`load_labels` calls during an end-to-end route; default path unchanged. + +--- + +- U4. **`apl-session-valkey` crate: connection layer + config** + +**Goal:** Scaffold the new feature-gated crate with config parsing and an internal redis-rs/deadpool connection module over rustls. + +**Requirements:** R10, R11, R13, R14 + +**Dependencies:** None (parallel with U1–U3) + +**Files:** +- Create: `crates/apl-session-valkey/Cargo.toml`, `src/lib.rs`, `src/config.rs`, `src/error.rs`, `src/connection.rs` +- Modify: root `Cargo.toml` (add to `members`, **not** `default-members`) +- Test: unit tests in `src/config.rs` (`#[cfg(test)]`) + +**Approach:** +- `redis = { version = "1.2", default-features = false, features = ["aio","tokio-comp","tokio-rustls-comp","connection-manager"] }`, `deadpool-redis = { version = "0.23", default-features = false, features = ["rt_tokio_1","tokio-rustls-comp"] }`, `thiserror`/`serde`/`serde_yaml`/`tracing`/`async-trait` from workspace. Document the rustls/no-native-tls rationale in `Cargo.toml` (mirror oauth/jwt comments). +- `config.rs`: parse endpoint (URL or host:port), TLS settings (TLS required when host is non-localhost — reject plaintext non-localhost at parse time, R10), auth (password/ACL, sourced from config/env), key prefix, TTL (optional; default off), and timeout/retry knobs with safe defaults. `BuildError` (thiserror) for malformed config. +- `connection.rs`: build the deadpool pool with connect + response timeouts and a jittered, budgeted reconnect policy. Internal module only (R14) — no public connection-layer API. +- File headers on every file (Location/Copyright/SPDX/Authors), `*.workspace = true` package fields. + +**Patterns to follow:** `crates/apl-pdp-cel/Cargo.toml` + layout; `reqwest` rustls feature lines in `apl-delegator-oauth`/`apl-identity-jwt`; `apl-cedarling` exclusion in root `Cargo.toml`. + +**Test scenarios:** +- Happy path: a well-formed YAML block parses into the config struct with expected endpoint/TLS/prefix/TTL. +- Edge case: non-localhost endpoint without TLS → `BuildError` (R10). +- Edge case: missing endpoint / unknown key → `BuildError`. +- Error path: TTL present but unparseable → `BuildError`. + +**Verification:** `cargo build -p apl-session-valkey` succeeds; `cargo build` (default-members) is unaffected; config unit tests pass; no native-tls/openssl in the dep tree (`cargo tree` shows rustls only). + +--- + +- U5. **`ValkeySessionStore`: atomic union, TTL, fail-closed mapping** + +**Goal:** Implement the trait against Valkey with an atomic SADD+EXPIRE, SMEMBERS load, the prefixed-SHA-256 key schema, and the R5/R15 error mapping. + +**Requirements:** R1, R5, R6, R7, R8, R9, R15, R16, R17 + +**Dependencies:** U1, U4 + +**Files:** +- Create: `crates/apl-session-valkey/src/store.rs` +- Test: `crates/apl-session-valkey/tests/valkey_store_integration.rs` (U7 owns the harness; basic per-method assertions can start here) + +**Approach:** +- Key = `:`; SET value-space. +- `append_labels`: `pipe().atomic().sadd(key, members).ignore()` and, when TTL configured, `.expire(key, ttl).ignore()` — one MULTI/EXEC round trip (R16); inspect EXEC replies and map any error to `Err`. +- `load_labels`: `SMEMBERS key` → `Ok(set)`; on configured TTL, refresh via `EXPIRE` that is **fail-open for the request** — return the read labels `Ok` and alarm on refresh failure (the read succeeded; see Key Technical Decisions). `Ok(empty)` for a missing key (R15). +- Error mapping (R5): `is_timeout|is_io_error|is_connection_dropped` or `ErrorKind::Parse|UnexpectedReturnType` → `SessionStoreError`. Primary-only connection (R6 — no replica routing). +- Startup self-checks: `CONFIG GET maxmemory-policy` → warn if not `noeviction` (R9); warn if configured TTL < declared max session-identity lifetime (R17). Exact timing per Deferred-to-Implementation. + +**Execution note:** Implement append/load test-first against the U7 container harness. + +**Patterns to follow:** redis-rs `pipe().atomic()` and `RedisError` predicates (External References); R15/R5 mapping sketch from the brainstorm. + +**Test scenarios:** +- Happy path: append then load round-trips the union (single node). Covers AE4 (single-node leg). +- Edge case: unknown session → `Ok(empty)` (key-miss, not error). Covers R15. +- Edge case: concurrent appends from two connections → final SMEMBERS is the full union (regression test for client-side RMW). Covers R16. +- Error path: unreachable endpoint → `Err` (fail-closed). Covers AE1/R5. +- Error path: reachable but undecodable reply → `Err`, not empty. Concrete mechanism: pre-seed the key as a non-SET type so `SMEMBERS`/EXEC returns `WRONGTYPE` → maps to `ErrorKind::UnexpectedReturnType`/`Parse` → `Err`. Covers R5 (closes the gap that fake-store and unreachable-container tests both miss). +- TTL: append/load at sub-TTL refreshes expiry; SADD alone does not reset a refreshed TTL unexpectedly. Covers AE2. +- TTL refresh fail-open: load succeeds but the `EXPIRE` refresh fails (e.g. ACL-denied EXPIRE) → returns `Ok(labels)` plus an alarm, not `Err`. + +**Verification:** Integration tests (U7) pass against a live container; error mapping distinguishes key-miss from connection/protocol failure. + +--- + +- U6. **Valkey factory + feature-gated FFI wiring** + +**Goal:** Implement `ValkeySessionStoreFactory` and wire it into `cpex-ffi` behind the `valkey` cargo feature. + +**Requirements:** R2, R13; AE5 + +**Dependencies:** U3, U5 + +**Files:** +- Create: `crates/apl-session-valkey/src/factory.rs` (+ `pub const KIND = "valkey"` in `lib.rs`) +- Modify: `crates/cpex-ffi/Cargo.toml` (optional dep + `valkey = ["dep:apl-session-valkey"]`) +- Modify: `crates/cpex-ffi/src/apl.rs` (`#[cfg(feature = "valkey")]` register the factory into `session_store_factories`) +- Test: `crates/apl-session-valkey/tests/valkey_store_integration.rs` (selection path) + +**Approach:** +- `ValkeySessionStoreFactory`: `kind() = "valkey"`, `build(cfg)` parses config (U4) and constructs `Arc`. +- FFI: add the optional dependency and feature exactly like `cedarling`; under `#[cfg(feature = "valkey")]`, push the factory into `opts.session_store_factories` in `cpex_apl_install`. Default (feature off) build is byte-for-byte unchanged. + +**Patterns to follow:** `CelPdpFactory`/`CedarDirectPdpFactory`; `cedarling` feature wiring in `cpex-ffi/Cargo.toml` and `apl.rs`. + +**Test scenarios:** +- Happy path: `kind: valkey` config block + registered factory → `ValkeySessionStore` is the active store end-to-end. Covers AE5. +- Edge case: feature off → no Valkey symbols linked; default FFI build/test unchanged. +- Error path: malformed valkey block → config load fails with a clear error. + +**Verification:** `cargo build -p cpex-ffi` (no features) unchanged; `cargo build -p cpex-ffi --features valkey` links the backend; AE5 passes. + +--- + +- U7. **Integration tests + Valkey container** + +**Goal:** Container-backed integration tests covering the cross-node and failure behaviors, skipping loudly without Docker and enforced in CI. + +**Requirements:** R12; AE2, AE4; verifies R5, R6, R16, R18 + +**Dependencies:** U5, U6 + +**Files:** +- Create: `crates/apl-session-valkey/tests/valkey_store_integration.rs` +- Create: `deploy/valkey-compose.yml` +- Modify: `crates/apl-session-valkey/Cargo.toml` (`[dev-dependencies]` testcontainers-modules valkey, tokio test features) +- Modify: CI workflow (env gate `REQUIRE_VALKEY_TESTS=1`) — note in Documentation if CI config lives outside this repo + +**Approach:** +- `testcontainers-modules` valkey image, tag pinned to the prod version. Tests `#[ignore]` by default, run via a dedicated `--ignored` job. +- Skip-cleanly: when Docker is absent and `REQUIRE_VALKEY_TESTS` is unset → `eprintln!` a loud SKIPPED line and return; when the env var is set (CI) → a `.start()` failure is a hard `panic!`. No silent `Ok(())`. +- Cross-node union: simulate two nodes by two pool connections appending concurrently; assert the union (AE4, R16). + +**Patterns to follow:** `apl-pdp-cel/tests/visitor_cel_config.rs` harness; `mockito` skip-discipline precedent; PR #67 anti-pattern (no silent no-op). + +**Test scenarios:** +- Integration: append on connection A, load on connection B → unioned labels (AE4). +- Integration: TTL refresh on load/append at sub-TTL extends expiry (AE2). +- Integration: `CONFIG GET maxmemory-policy` is asserted `noeviction` in the test container; `evicted_keys == 0`. +- Integration: restricted ACL user denied a command outside its grant (asserts the error). +- Error path: stopped/unreachable container → store returns `Err` (fail-closed, R5). +- Edge case: Docker absent without the env gate → test prints SKIPPED and returns; with the gate set → hard failure. + +**Verification:** Tests pass against a live container locally and in the CI `--ignored` job; the suite cannot green-wash when the container is missing in CI. + +--- + +- U8. **Operator documentation** + +**Goal:** Runbook covering the operator-owned controls the backend depends on. + +**Requirements:** R8, R9, R10 + +**Dependencies:** U4 (config shape stable) + +**Files:** +- Create: `docs/operations/valkey-session-store.md` + +**Approach:** +- Document: `maxmemory-policy noeviction` (+ `evicted_keys == 0` monitoring), least-privilege ACL (`on >secret resetchannels -@all ~taint:v1:* +sadd +smembers +expire +config|get`), TLS/mTLS setup and the non-localhost TLS requirement, the TTL soundness rule (TTL ≥ max session-identity lifetime) and the startup warning, the sliding-TTL refresh-failure alarm (a persistently-failing refresh risks silent key expiry → taint loss), credential handling/rotation (overlap rotation; mTLS auto-reload), HA via a fronting endpoint. +- **Blast radius (state precisely):** the availability tradeoff is "a Valkey outage → fail-closed denial of **session-bearing** requests." Anonymous/sessionless traffic (no resolved `session_id`) loads no state and is **not** denied by a store outage — do not overstate it as fleet-wide. + +**Test scenarios:** Test expectation: none — documentation only. + +**Verification:** Runbook covers every operator-owned precondition referenced by R8/R9/R10 and the Key Decisions availability tradeoff. + +--- + +## System-Wide Impact + +- **Interaction graph:** The trait change touches every `SessionStore` consumer — `MemorySessionStore`, `CmfPluginInvoker` (`for_request`, `persist_session`), `AplRouteHandler::invoke`, and all test files constructing `MemorySessionStore` (~10 under `crates/apl-cpex/tests/`, plus `apl-pdp-cel`/`apl-pdp-cedar-direct` visitor tests). The string-typed trait is also the surface future apl-mcp/apl-langgraph bridges inherit — the `Result` becomes part of their contract (intended, R4). +- **Error propagation:** Load error → `Box` out of `invoke` (host failure, pre-decision). Append error → `continue_processing=false` + violation (Deny, post-decision) + distinguished alarm. Build/config error → `VisitorError` failing `load_config_yaml`. +- **State lifecycle risks:** Concurrent cross-node append must be server-side SADD (U5/R16) — client-side RMW would lose labels. TTL refresh-on-load is a write; a refresh failure must not corrupt an already-successful read (R7). +- **API surface parity:** `AplOptions` gains `session_store_factories`; existing callers using `AplOptions::in_process()` are unaffected (empty factory list → memory default). +- **Integration coverage:** Cross-node union, fail-closed-on-unreachable, append-fail-closed→Deny, and key-miss→empty are not provable by unit mocks alone — covered by U2 (fake-store) and U7 (live container). +- **Unchanged invariants:** Monotonic union semantics and unknown-session→empty are preserved across both backends (R15). Default (no config) behavior is byte-for-byte unchanged (R3, AE3); default FFI artifact size unchanged when the `valkey` feature is off (R13). + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Trait `Result` change ripples to ~10 test files + bridges; noisy diff | U1 lands the signature + memory adaptation atomically; mechanical `.unwrap()`/`?` updates; CI `--workspace` catches stragglers. | +| Config-seam late-binding subtle (walk order vs request time) | `ArcSwap` handle decouples build-time from request-time; U3 tests the default-then-swap path explicitly. | +| Fail-closed couples fleet availability to one Valkey (accepted tradeoff) | Documented (origin Key Decisions + U8); bounded by connect/command timeouts, ≤1 budgeted retry, circuit breaker → immediate fail-closed. | +| Integration tests silently no-op without Docker (PR #67 lesson) | Loud SKIPPED + CI env gate (`REQUIRE_VALKEY_TESTS=1`) makes a missing container a hard CI failure. | +| Client/dep weight creeping into FFI artifact | `default-features=false` + feature gate + `default-members` exclusion (U4/U6); verify with `cargo tree` and artifact-size check. | +| `noeviction` is operator-owned; the client can't enforce it | Startup `CONFIG GET maxmemory-policy` warning (U5/R9) + runbook + `evicted_keys` monitoring (U8). | +| Config live-reload would swap the store under in-flight requests holding the prior `Arc` | v0 does not support session-store live-reload (Deferred to Implementation); documented limitation. `ArcSwap` handle is the future fix if reload is required. | +| Persistently-failing sliding-TTL refresh silently expires keys → cross-request taint loss | Refresh failure is alarmed (U5); runbook calls out the monitoring signal (U8); `noeviction` does not cover TTL expiry, so the alarm is the control. | + +--- + +## Documentation / Operational Notes + +- New operator runbook `docs/operations/valkey-session-store.md` (U8). +- New `deploy/valkey-compose.yml` for local dev/integration. +- CI gains an `--ignored` Valkey integration job with `REQUIRE_VALKEY_TESTS=1`; if CI config lives in another repo, flag the change there. +- After merge, capture the trait-change and client decisions in a durable learnings location (no `docs/solutions/` exists today). + +--- + +## Sources & References + +- **Origin document:** [docs/brainstorms/valkey-session-store-requirements.md](../brainstorms/valkey-session-store-requirements.md) +- Trait + call sites: `crates/apl-cpex/src/session_store.rs`, `crates/apl-cpex/src/cmf_invoker.rs`, `crates/apl-cpex/src/route_handler.rs` +- Factory pattern: `crates/apl-core/src/step.rs`, `crates/apl-cpex/src/visitor.rs`, `crates/apl-cpex/src/register.rs` +- Feature-gate precedent: `crates/cpex-ffi/Cargo.toml`, root `Cargo.toml`, `crates/cpex-ffi/src/apl.rs` +- Reference crate: `crates/apl-pdp-cel/` (layout, factory, error, tests) +- rustls discipline: `crates/apl-delegator-oauth/Cargo.toml`, `crates/apl-identity-jwt/Cargo.toml` +- External: redis-rs (docs.rs/redis), deadpool-redis, Valkey docs (transactions/expire/eviction/replication/acl/tls), testcontainers-modules, AWS Builders' Library (timeouts/retries/circuit-breaker) From b3faea1745588174c84334dccd0348e9fcf32504 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 18 Jun 2026 22:41:37 -0400 Subject: [PATCH 16/64] chore: update changelog Signed-off-by: Frederico Araujo --- CHANGELOG.md | 56 +++++++++++----------------------------------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d020ff..d505701b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,55 +17,23 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added -- APL (Attribute Policy Language) governance is now bundled into - `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers - the standard APL plugin/PDP factories (`validator/pii-scan`, - `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and - installs the APL config visitor on a manager. Call it after - `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use - `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds - the Cedarling-backed identity + PDP seams (off by default; the released - `.a` stays lean). -- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on - every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, - `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign - keyless signatures + SHA256 checksums; see - `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and- - consume recipe. -- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor - exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` - and panics on mismatch. Other language bindings must replicate the - check. +- APL (Attribute Policy Language) governance is now bundled into `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers the standard APL plugin/PDP factories (`validator/pii-scan`, `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and installs the APL config visitor on a manager. Call it after `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds the Cedarling-backed identity + PDP seams (off by default; the released `.a` stays lean). (#60) +- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign keyless signatures + SHA256 checksums; see `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and-consume recipe. (#60) +- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` and panics on mismatch. Other language bindings must replicate the check. (#60) +- CEL (Common Expression Language) policy decision backend. A new `apl-pdp-cel` crate registers `kind: cel`, letting authors write inline boolean predicates (`cel: { expr: ... }`) over the common attribute vocabulary (`subject.id`, `delegation.depth`, `session.labels`, ...), evaluated through the existing `PdpResolver` seam alongside Cedar, OPA, and AuthZen. Expressions compile once and cache by source; compile errors, undeclared-variable references, and non-boolean results fail closed (deny), overridable with `on_error: allow`. No change to APL evaluation semantics. (#68) +- APL authoring ergonomics (backwards-compatible). The `apl:` wrapper is now optional — recognized APL terms (`policy`, `post_policy`, `args`, `result`, `pdp`, `session_store`) written directly on a section are honored, with the explicit `apl:` form still taking precedence. `run(name)` is accepted as an alias for `plugin(name)` in both policy steps and field pipelines. Unconditional `deny('reason')` / `deny('reason', 'code')` now parses as a bare action (e.g. in `on_deny:` lists), so a reason/code can be attached without a conditional. (#71) +- Valkey-backed `SessionStore` for cross-node and cross-restart session label propagation. Selectable via a `kind: valkey` block under `global.apl.session_store` (factory pattern mirroring `pdp`), shipped in the `apl-session-valkey` crate and wired into `cpex-ffi` behind the optional `valkey` cargo feature (the default build and `.a` artifact are unaffected). Labels live in a Redis SET so appends are an atomic server-side union (`SADD`); the store is fail-closed (a load/append error denies the request rather than under-labeling), serves primary-only reads, supports an optional sliding TTL, requires TLS for non-localhost endpoints, and SHA-256s session ids out of the keyspace. When no block is configured the default remains the in-process memory store. See the operator runbook at `docs/operations/valkey-session-store.md`. (#74) ### Changed -- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` - extern C function and changed `cpex_load_config` to run registered - config visitors (it now calls `load_config_yaml` internally so `apl:` - blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped - in lockstep. -- Size-first `[profile.release]`: `opt-level = "z"`, `lto = true`, - `codegen-units = 1`, `strip = true`. `libcpex_ffi.a` is linked statically - into host binaries, so this flows straight into their image size — a - representative statically-linked consumer shrank ~21%. `panic = "abort"` - is intentionally not set (the FFI relies on `catch_unwind` at its - `#[no_mangle]` boundary). No API or ABI change. -- Trimmed the workspace `tokio` feature floor from `["full"]` to - `["rt", "rt-multi-thread", "sync", "time", "macros"]` — the union of what - the crates actually use; `reqwest`/`hyper` still pull `net`/`io` where they - need them via feature unification. Drops the unused `fs`/`process`/`signal` - surface (and the `signal-hook-registry` dependency). +- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` extern C function and changed `cpex_load_config` to run registered config visitors (it now calls `load_config_yaml` internally so `apl:` blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped in lockstep. (#60) +- Size-first `[profile.release]`: `opt-level = "z"`, `lto = true`, `codegen-units = 1`, `strip = true`. `libcpex_ffi.a` is linked statically into host binaries, so this flows straight into their image size — a representative statically-linked consumer shrank ~21%. `panic = "abort"` is intentionally not set (the FFI relies on `catch_unwind` at its `#[no_mangle]` boundary). No API or ABI change. (#69) +- Trimmed the workspace `tokio` feature floor from `["full"]` to `["rt", "rt-multi-thread", "sync", "time", "macros"]` — the union of what the crates actually use; `reqwest`/`hyper` still pull `net`/`io` where they need them via feature unification. Drops the unused `fs`/`process`/`signal` surface (and the `signal-hook-registry` dependency). (#69) +- `SessionStore` trait methods (`load_labels` / `append_labels`) now return `Result` so backend failures propagate to callers — the error channel fail-closed requires. `MemorySessionStore` is infallible and adapts trivially; the CMF invoker (`for_request` / `persist_session`) and the route handler propagate the error and fail the request closed on a load/append failure. This is part of the shared `SessionStore` contract that future bridges inherit. (#74) ### Fixed -- Cedar evaluation no longer fails with "recursion limit reached" on hosts - that give the FFI a small thread stack (notably musl, whose default is - 128 KiB). `cedar-policy` aborts when `stacker::remaining_stack()` is below - its 100 KiB floor; the cedar dispatch in `apl-pdp-cedar-direct` is now - wrapped in `stacker::maybe_grow`, so it runs on an adequately sized stack - regardless of the host (a no-op when there is already headroom, e.g. - glibc's 8 MiB threads). Regression test exercises a real evaluation on a - 128 KiB stack. +- Cedar evaluation no longer fails with "recursion limit reached" on hosts that give the FFI a small thread stack (notably musl, whose default is 128 KiB). `cedar-policy` aborts when `stacker::remaining_stack()` is below its 100 KiB floor; the cedar dispatch in `apl-pdp-cedar-direct` is now wrapped in `stacker::maybe_grow`, so it runs on an adequately sized stack regardless of the host (a no-op when there is already headroom, e.g. glibc's 8 MiB threads). Regression test exercises a real evaluation on a 128 KiB stack. (#69) ## [0.1.0] - 2026-05-05 @@ -75,4 +43,4 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). [Unreleased]: https://github.com/contextforge-org/cpex/compare/0.1.1...HEAD [0.1.1]: https://github.com/contextforge-org/cpex/compare/0.1.0...0.1.1 -[0.1.0]: https://github.com/contextforge-org/cpex/releases/tag/0.1.0 \ No newline at end of file +[0.1.0]: https://github.com/contextforge-org/cpex/releases/tag/0.1.0 From 494efd985d13b39ddade67ccf54bd558cc2d8e60 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 06:20:51 +0200 Subject: [PATCH 17/64] feat: add cpex host facade crate (#77) Add a `cpex` crate that re-exports the host runtime (PluginManager, AplOptions, register_apl) and the bundled plugin factories, each behind a cargo feature: jwt, oauth, pii, audit, cedar, cel, valkey. Hosts depend on this one crate and enable the plugins they want instead of pinning apl-cmf / apl-cpex / apl-pdp-* / apl-session-* individually. `install_builtins(&mgr)` registers every enabled factory and installs the APL config visitor in one call; register_builtin_plugins, builtin_pdp_factories, and builtin_session_store_factories expose the pieces for hosts that assemble AplOptions themselves. Add the crate to workspace members and default-members. Signed-off-by: Frederico Araujo --- Cargo.lock | 17 ++++ Cargo.toml | 2 + crates/cpex/Cargo.toml | 61 ++++++++++++++ crates/cpex/src/lib.rs | 179 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 crates/cpex/Cargo.toml create mode 100644 crates/cpex/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1ebccc30..7ff8793a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1050,6 +1050,23 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpex" +version = "0.2.0" +dependencies = [ + "apl-audit-logger", + "apl-cmf", + "apl-core", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pdp-cel", + "apl-pii-scanner", + "apl-session-valkey", + "cpex-core", +] + [[package]] name = "cpex-core" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 01c6bc85..2c5fbdd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ [workspace] resolver = "2" members = [ + "crates/cpex", "crates/cpex-core", "crates/cpex-orchestration", "crates/cpex-sdk", @@ -38,6 +39,7 @@ members = [ # cargo build -p apl-cedarling # just this one # cargo test --workspace # full sweep (CI) default-members = [ + "crates/cpex", "crates/cpex-core", "crates/cpex-orchestration", "crates/cpex-sdk", diff --git a/crates/cpex/Cargo.toml b/crates/cpex/Cargo.toml new file mode 100644 index 00000000..7139fec4 --- /dev/null +++ b/crates/cpex/Cargo.toml @@ -0,0 +1,61 @@ +# Location: ./crates/cpex/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Fred Araujo +# +# cpex — batteries-included host facade. +# +# One dependency instead of a dozen: re-exports the host runtime +# (PluginManager, AplOptions, register_apl) plus the bundled plugin +# factories, each gated behind a cargo feature. A host enables the +# plugins it wants and gets them through this single crate rather than +# pinning apl-cmf / apl-cpex / apl-pdp-* / apl-session-* individually. +# +# cpex = { version = "0.2.0", features = ["jwt", "oauth", "cedar", "cel", "valkey"] } +# +# then `cpex::install_builtins(&mgr)` registers every enabled factory and +# installs the APL config visitor in one call. + +[package] +name = "cpex" +description = "CPEX host facade — re-exports the runtime and feature-gated plugin factories." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +# No plugins are forced on a re-export-only consumer, but the common +# in-process set is on by default so `cpex = "0.2"` is useful out of the +# box. Heavier / external-backend plugins (valkey, and future cedarling) +# stay opt-in, mirroring the workspace's default-members policy. +default = ["jwt", "oauth", "pii", "audit", "cedar", "cel"] + +# Individual plugins, each pulling exactly one apl-* crate. +jwt = ["dep:apl-identity-jwt"] +oauth = ["dep:apl-delegator-oauth"] +pii = ["dep:apl-pii-scanner"] +audit = ["dep:apl-audit-logger"] +cedar = ["dep:apl-pdp-cedar-direct"] +cel = ["dep:apl-pdp-cel"] +valkey = ["dep:apl-session-valkey"] + +# Everything the facade knows how to wire, including the Valkey session +# store (redis client + rustls TLS stack). +full = ["jwt", "oauth", "pii", "audit", "cedar", "cel", "valkey"] + +[dependencies] +# Host runtime — always present, this is the point of the facade. +cpex-core = { path = "../cpex-core" } +apl-core = { path = "../apl-core" } +apl-cmf = { path = "../apl-cmf" } +apl-cpex = { path = "../apl-cpex" } + +# Bundled plugin factories — each behind its feature. +apl-identity-jwt = { path = "../apl-identity-jwt", optional = true } +apl-delegator-oauth = { path = "../apl-delegator-oauth", optional = true } +apl-pii-scanner = { path = "../apl-pii-scanner", optional = true } +apl-audit-logger = { path = "../apl-audit-logger", optional = true } +apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct", optional = true } +apl-pdp-cel = { path = "../apl-pdp-cel", optional = true } +apl-session-valkey = { path = "../apl-session-valkey", optional = true } diff --git a/crates/cpex/src/lib.rs b/crates/cpex/src/lib.rs new file mode 100644 index 00000000..b5da2a1d --- /dev/null +++ b/crates/cpex/src/lib.rs @@ -0,0 +1,179 @@ +// Location: ./crates/cpex/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo + +//! CPEX host facade. +//! +//! A single dependency that re-exports the CPEX host runtime and the +//! bundled APL plugin factories, each behind a cargo feature. Hosts +//! depend on this crate instead of pinning `apl-cmf`, `apl-cpex`, +//! `apl-pdp-*`, `apl-session-*`, and friends one by one. +//! +//! # Usage +//! +//! ```toml +//! cpex = { version = "0.2.0", features = ["jwt", "oauth", "cedar", "cel", "valkey"] } +//! ``` +//! +//! ```no_run +//! use std::sync::Arc; +//! use cpex::PluginManager; +//! +//! let mgr = Arc::new(PluginManager::default()); +//! // Register every enabled plugin 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. +//! ``` +//! +//! For finer control, the building blocks are public: +//! [`register_builtin_plugins`] registers the by-kind plugin factories, +//! [`builtin_pdp_factories`] / [`builtin_session_store_factories`] return +//! the enabled factories for an [`AplOptions`] you assemble yourself, and +//! every concrete factory type is re-exported under its feature. +//! +//! # Features +//! +//! `jwt`, `oauth`, `pii`, `audit`, `cedar`, `cel` are on by default. +//! `valkey` (Valkey-backed session store; pulls a redis client and a +//! rustls TLS stack) is opt-in. `full` enables everything. + +use std::sync::Arc; + +// ----------------------------------------------------------------------------- +// Host runtime re-exports (always available) +// ----------------------------------------------------------------------------- + +// Whole-crate re-exports for advanced use (types not surfaced below). +pub use {apl_cmf, apl_core, apl_cpex, cpex_core}; + +pub use apl_core::step::PdpFactory; +pub use apl_cpex::{ + register_apl, AplOptions, DispatchCache, MemorySessionStore, SessionStore, SessionStoreFactory, +}; +pub use cpex_core::manager::PluginManager; + +// ----------------------------------------------------------------------------- +// Bundled plugin factories (feature-gated) +// ----------------------------------------------------------------------------- + +#[cfg(feature = "audit")] +pub use apl_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; +#[cfg(feature = "oauth")] +pub use apl_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND}; +#[cfg(feature = "jwt")] +pub use apl_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; +#[cfg(feature = "cedar")] +pub use apl_pdp_cedar_direct::CedarDirectPdpFactory; +#[cfg(feature = "cel")] +pub use apl_pdp_cel::CelPdpFactory; +#[cfg(feature = "pii")] +pub use apl_pii_scanner::{PiiScannerFactory, KIND as PII_KIND}; +#[cfg(feature = "valkey")] +pub use apl_session_valkey::{ValkeyConfig, ValkeySessionStoreFactory, KIND as VALKEY_KIND}; + +// ----------------------------------------------------------------------------- +// Registration helpers +// ----------------------------------------------------------------------------- + +/// Register every enabled by-kind plugin factory on `mgr`: identity +/// (`jwt`), delegators (`oauth`), validators (`pii`), and observers +/// (`audit`). 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_pdp_factories`] and +/// [`builtin_session_store_factories`], or use [`install_builtins`]. +#[allow(unused_variables)] +pub fn register_builtin_plugins(mgr: &Arc) { + #[cfg(feature = "jwt")] + mgr.register_factory(JWT_KIND, Box::new(JwtIdentityFactory)); + #[cfg(feature = "oauth")] + mgr.register_factory(OAUTH_KIND, Box::new(OAuthDelegatorFactory)); + #[cfg(feature = "pii")] + mgr.register_factory(PII_KIND, Box::new(PiiScannerFactory)); + #[cfg(feature = "audit")] + mgr.register_factory(AUDIT_KIND, Box::new(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_pdp_factories() -> Vec> { + let mut factories: Vec> = Vec::new(); + #[cfg(feature = "cedar")] + factories.push(Arc::new(CedarDirectPdpFactory::new())); + #[cfg(feature = "cel")] + factories.push(Arc::new(CelPdpFactory::new())); + factories +} + +/// The enabled session-store factories, ready to drop into +/// [`AplOptions::session_store_factories`]. A `global.session_store: +/// { kind: ... }` config block selects one; absent that, the +/// [`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(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_builtin_plugins`] and +/// [`AplOptions`] directly when you need to customize capabilities or the +/// default store. +pub fn install_builtins(mgr: &Arc) { + register_builtin_plugins(mgr); + + let mut opts = AplOptions::in_process(); + opts.pdp_factories = builtin_pdp_factories(); + opts.session_store_factories = builtin_session_store_factories(); + + let _visitor = register_apl(mgr, opts); +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[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") as usize + cfg!(feature = "cel") as usize; + assert_eq!( + builtin_pdp_factories().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", + ); + } +} From 702ab163c64fe762318cb2bc5fb9b088869dd1af Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 00:23:21 -0400 Subject: [PATCH 18/64] chore: update changelog for cpex host facade crate (#77) Signed-off-by: Frederico Araujo --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d505701b..01ba2ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - CEL (Common Expression Language) policy decision backend. A new `apl-pdp-cel` crate registers `kind: cel`, letting authors write inline boolean predicates (`cel: { expr: ... }`) over the common attribute vocabulary (`subject.id`, `delegation.depth`, `session.labels`, ...), evaluated through the existing `PdpResolver` seam alongside Cedar, OPA, and AuthZen. Expressions compile once and cache by source; compile errors, undeclared-variable references, and non-boolean results fail closed (deny), overridable with `on_error: allow`. No change to APL evaluation semantics. (#68) - APL authoring ergonomics (backwards-compatible). The `apl:` wrapper is now optional — recognized APL terms (`policy`, `post_policy`, `args`, `result`, `pdp`, `session_store`) written directly on a section are honored, with the explicit `apl:` form still taking precedence. `run(name)` is accepted as an alias for `plugin(name)` in both policy steps and field pipelines. Unconditional `deny('reason')` / `deny('reason', 'code')` now parses as a bare action (e.g. in `on_deny:` lists), so a reason/code can be attached without a conditional. (#71) - Valkey-backed `SessionStore` for cross-node and cross-restart session label propagation. Selectable via a `kind: valkey` block under `global.apl.session_store` (factory pattern mirroring `pdp`), shipped in the `apl-session-valkey` crate and wired into `cpex-ffi` behind the optional `valkey` cargo feature (the default build and `.a` artifact are unaffected). Labels live in a Redis SET so appends are an atomic server-side union (`SADD`); the store is fail-closed (a load/append error denies the request rather than under-labeling), serves primary-only reads, supports an optional sliding TTL, requires TLS for non-localhost endpoints, and SHA-256s session ids out of the keyspace. When no block is configured the default remains the in-process memory store. See the operator runbook at `docs/operations/valkey-session-store.md`. (#74) +- `cpex` host facade crate: a single dependency that re-exports the host runtime (`PluginManager`, `AplOptions`, `register_apl`) and the bundled plugin factories, each behind a cargo feature (`jwt`, `oauth`, `pii`, `audit`, `cedar`, `cel`, `valkey`). Hosts depend on `cpex` and enable the plugins they want instead of pinning `apl-cmf` / `apl-cpex` / `apl-pdp-*` / `apl-session-*` individually. `install_builtins(&mgr)` registers every enabled factory and installs the APL config visitor in one call; `register_builtin_plugins`, `builtin_pdp_factories`, and `builtin_session_store_factories` expose the pieces for hosts that assemble `AplOptions` themselves. (#77) ### Changed From bb49d040854824bb8a4a594680452e701d29f84b Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 24 Jun 2026 14:49:05 +0200 Subject: [PATCH 19/64] chore: bump quinn-proto to 0.11.15 for RUSTSEC-2026-0185 (#78) quinn-proto < 0.11.15 is flagged by RUSTSEC-2026-0185 (remote memory exhaustion via unbounded out-of-order stream reassembly). It is pinned in the lockfile only as reqwest's optional http3 dependency and is never compiled. Bump the pin to the patched 0.11.15; semver-compatible, no build impact. Signed-off-by: Frederico Araujo --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ff8793a..80e2d5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3371,9 +3371,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", From b01e0dda011ccdf164ef616409046b9f754451a0 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 24 Jun 2026 19:11:10 +0200 Subject: [PATCH 20/64] refactor: cpex-builtins aggregator + builtins/ layout, and remove Cedarling (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: extract cpex-builtins aggregator; cpex engine-only by default (#72) Introduce a `cpex-builtins` crate as the single source of truth for the bundled extension set (plugins, PDPs, session stores). A declarative `register_builtins!` macro expands to explicit, #[cfg]-gated register_factory calls — kept explicit (not inventory/linkme) so factory symbols survive the linker GC inside libcpex_ffi.a. The `cpex` facade and `cpex-ffi` now both delegate to cpex-builtins, removing the duplicated hand-wired registration. The facade is now engine-only by default; builtins are opt-in via `builtins`/`full` (granular plugin features preserved as passthroughs). cpex-ffi keeps its prior bundle (four hook plugins + cedar-direct) via an explicit feature subset. PluginFactoryRegistry::register now warns when it overwrites an existing kind. No FFI ABI change; KIND strings and exported symbols unchanged. Phase 1 of issue #72 (mechanism + de-dup). Directory move and the apl-* -> cpex-plugin-* rename follow in subsequent phases. * refactor: move builtin plugins/PDPs/session stores under builtins/ (#72) Relocate the bundled extension crates out of the flat crates/ directory into a builtins/ tree that mirrors the registration seams: builtins/plugins/ (pii-scanner, audit-logger, identity-jwt, delegator-oauth, delegator-biscuit) builtins/pdps/ (cedar-direct, cel) builtins/session/ (valkey) builtins/cedarling/ This is a pure path move: crate names are unchanged, so there are no import/use changes. Updates workspace members/default-members, the path dependencies in the moved crates and their consumers (cpex-builtins, cpex-ffi), and the per-file Location: headers. The policy crates (apl-core/apl-cmf/apl-cpex) stay in crates/ (out of scope per #72). Phase 2 of issue #72. * refactor: drop apl- prefix on builtin crates -> cpex-plugin-*/cpex-pdp-* (#72) Rename the bundled extension crates off the apl- prefix (they are CPEX plugins that use APL hooks, not APL itself), now that they live under builtins/: apl-pii-scanner -> cpex-plugin-pii-scanner apl-audit-logger -> cpex-plugin-audit-logger apl-identity-jwt -> cpex-plugin-identity-jwt apl-delegator-oauth -> cpex-plugin-delegator-oauth apl-delegator-biscuit -> cpex-plugin-delegator-biscuit apl-pdp-cedar-direct -> cpex-pdp-cedar-direct apl-pdp-cel -> cpex-pdp-cel apl-session-valkey -> cpex-session-valkey apl-cedarling -> cpex-cedarling Updates package names, dependency keys (cpex-builtins, cpex-ffi), crate-path identifiers in sources, workspace members, and operator-facing docs. The policy crates (apl-core/apl-cmf/apl-cpex) keep their names (out of scope per #72). Config-facing `kind:` strings and the FFI C ABI are unchanged; full workspace build + tests green and the Go demo runs. Phase 3 (final) of issue #72. * style: cargo fmt cpex-builtins and cpex facade re-exports (#72) * refactor: remove Cedarling (crate + APL dialect) to drop the git dependency cpex-cedarling was a Sub-step A stub ("Module skeletons + crate wiring only. No actual Cedarling calls.") and pulled the `cedarling` crate via a git dependency on the Janssen monorepo (~200 transitive crates) — the only git dep in the workspace and the sole blocker to publishing every crate on crates.io. It was wired nowhere: nothing outside the crate instantiated it, no YAML config used `kind: cedarling` or a `cedarling:` step, no Go binding referenced it, and CI never built `--features cedarling`. So this is a no-op for runtime behavior. Removed: - the builtins/cedarling crate - cpex-ffi's optional cpex-cedarling dep and its `cedarling` feature - the `cedarling` PDP dialect from the APL grammar: PdpDialect::Cedarling, the `cedarling:` step-prefix detection, the is_known_pdp_dialect arm, and the dedicated parser test. Stray `cedarling` strings now degrade to PdpDialect::Custom, consistent with the resolver-less opa/authzen/nemo dialects, so a host can still supply its own Cedarling resolver. Also trimmed stale cpex-cedarling references from neighbouring crate docs and RELEASE.md, and added a CHANGELOG Removed entry. Remaining PDP `kind:` strings (cedar-direct, cel) and the FFI C ABI are unchanged. Verified: cargo build/test --workspace green (no Janssen git fetch; Cargo.lock has no cedarling/jans entries); `cargo build -p cpex-ffi --features cedarling` now errors (feature gone) while `--features valkey` still builds; the Go demo runs with unchanged output. --------- Signed-off-by: Frederico Araujo --- CHANGELOG.md | 10 +- Cargo.lock | 1447 ++--------------- Cargo.toml | 46 +- .../pdps/cedar-direct}/Cargo.toml | 38 +- .../pdps/cedar-direct}/src/cedar_attrs.rs | 2 +- .../pdps/cedar-direct}/src/decision.rs | 2 +- .../pdps/cedar-direct}/src/entities.rs | 2 +- .../pdps/cedar-direct}/src/error.rs | 2 +- .../pdps/cedar-direct}/src/factory.rs | 2 +- .../pdps/cedar-direct}/src/lib.rs | 4 +- .../pdps/cedar-direct}/src/request.rs | 2 +- .../pdps/cedar-direct}/src/resolver.rs | 2 +- .../pdps/cedar-direct}/src/template.rs | 2 +- .../cedar-direct}/tests/basic_allow_deny.rs | 4 +- .../cedar-direct}/tests/small_stack_eval.rs | 4 +- .../cedar-direct}/tests/visitor_pdp_config.rs | 4 +- .../pdps/cel}/Cargo.toml | 14 +- .../pdps/cel}/src/activation.rs | 2 +- .../pdps/cel}/src/error.rs | 2 +- .../pdps/cel}/src/factory.rs | 2 +- .../pdps/cel}/src/lib.rs | 6 +- .../pdps/cel}/src/resolver.rs | 4 +- .../pdps/cel}/tests/visitor_cel_config.rs | 4 +- .../plugins/audit-logger}/Cargo.toml | 8 +- .../plugins/audit-logger}/src/config.rs | 2 +- .../plugins/audit-logger}/src/factory.rs | 4 +- .../plugins/audit-logger}/src/lib.rs | 4 +- .../plugins/audit-logger}/src/logger.rs | 4 +- .../plugins/delegator-biscuit}/Cargo.toml | 16 +- .../plugins/delegator-biscuit}/src/config.rs | 2 +- .../delegator-biscuit}/src/delegator.rs | 8 +- .../plugins/delegator-biscuit}/src/lib.rs | 4 +- .../delegator-biscuit}/tests/biscuit_e2e.rs | 4 +- .../plugins/delegator-oauth}/Cargo.toml | 16 +- .../plugins/delegator-oauth}/src/config.rs | 2 +- .../plugins/delegator-oauth}/src/delegator.rs | 16 +- .../plugins/delegator-oauth}/src/factory.rs | 2 +- .../plugins/delegator-oauth}/src/lib.rs | 4 +- .../delegator-oauth}/tests/oauth_e2e.rs | 4 +- .../plugins/identity-jwt}/Cargo.toml | 27 +- .../plugins/identity-jwt}/src/claim_map.rs | 2 +- .../plugins/identity-jwt}/src/config.rs | 2 +- .../plugins/identity-jwt}/src/factory.rs | 2 +- .../plugins/identity-jwt}/src/lib.rs | 18 +- .../plugins/identity-jwt}/src/resolver.rs | 18 +- .../identity-jwt}/src/trusted_issuer.rs | 2 +- .../identity-jwt}/tests/jwks_url_e2e.rs | 4 +- .../plugins/identity-jwt}/tests/jwt_e2e.rs | 4 +- .../plugins/pii-scanner}/Cargo.toml | 8 +- .../plugins/pii-scanner}/src/config.rs | 2 +- .../plugins/pii-scanner}/src/factory.rs | 4 +- .../plugins/pii-scanner}/src/lib.rs | 4 +- .../plugins/pii-scanner}/src/scanner.rs | 8 +- .../session/valkey}/Cargo.toml | 12 +- .../session/valkey}/src/config.rs | 2 +- .../session/valkey}/src/connection.rs | 2 +- .../session/valkey}/src/error.rs | 2 +- .../session/valkey}/src/factory.rs | 2 +- .../session/valkey}/src/lib.rs | 6 +- .../session/valkey}/src/store.rs | 2 +- .../valkey}/tests/valkey_store_integration.rs | 6 +- crates/apl-cedarling/Cargo.toml | 64 - crates/apl-cedarling/src/error.rs | 30 - crates/apl-cedarling/src/identity/mod.rs | 31 - crates/apl-cedarling/src/lib.rs | 48 - crates/apl-cedarling/src/pdp/mod.rs | 10 - crates/apl-cedarling/src/pdp/resolver.rs | 374 ----- crates/apl-cedarling/tests/pdp_basic.rs | 166 -- crates/apl-core/src/parser.rs | 36 +- crates/apl-core/src/step.rs | 28 +- crates/apl-cpex/src/pdp_router.rs | 6 +- crates/apl-cpex/src/register.rs | 4 +- crates/apl-cpex/src/session_resolver.rs | 6 +- crates/apl-cpex/src/session_store.rs | 2 +- crates/apl-cpex/src/visitor.rs | 2 +- crates/cpex-builtins/Cargo.toml | 58 + crates/cpex-builtins/src/lib.rs | 185 +++ crates/cpex-core/src/factory.rs | 10 +- crates/cpex-ffi/Cargo.toml | 40 +- crates/cpex-ffi/RELEASE.md | 4 +- crates/cpex-ffi/src/apl.rs | 72 +- crates/cpex/Cargo.toml | 59 +- crates/cpex/src/lib.rs | 173 +- deploy/valkey-compose.yml | 4 +- docs/operations/valkey-session-store.md | 4 +- 85 files changed, 761 insertions(+), 2501 deletions(-) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/Cargo.toml (58%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/cedar_attrs.rs (97%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/decision.rs (98%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/entities.rs (99%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/error.rs (97%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/factory.rs (96%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/lib.rs (96%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/request.rs (99%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/resolver.rs (99%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/src/template.rs (99%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/tests/basic_allow_deny.rs (98%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/tests/small_stack_eval.rs (96%) rename {crates/apl-pdp-cedar-direct => builtins/pdps/cedar-direct}/tests/visitor_pdp_config.rs (98%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/Cargo.toml (86%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/src/activation.rs (99%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/src/error.rs (96%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/src/factory.rs (96%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/src/lib.rs (96%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/src/resolver.rs (99%) rename {crates/apl-pdp-cel => builtins/pdps/cel}/tests/visitor_cel_config.rs (99%) rename {crates/apl-audit-logger => builtins/plugins/audit-logger}/Cargo.toml (77%) rename {crates/apl-audit-logger => builtins/plugins/audit-logger}/src/config.rs (94%) rename {crates/apl-audit-logger => builtins/plugins/audit-logger}/src/factory.rs (91%) rename {crates/apl-audit-logger => builtins/plugins/audit-logger}/src/lib.rs (91%) rename {crates/apl-audit-logger => builtins/plugins/audit-logger}/src/logger.rs (98%) rename {crates/apl-delegator-biscuit => builtins/plugins/delegator-biscuit}/Cargo.toml (80%) rename {crates/apl-delegator-biscuit => builtins/plugins/delegator-biscuit}/src/config.rs (98%) rename {crates/apl-delegator-biscuit => builtins/plugins/delegator-biscuit}/src/delegator.rs (96%) rename {crates/apl-delegator-biscuit => builtins/plugins/delegator-biscuit}/src/lib.rs (89%) rename {crates/apl-delegator-biscuit => builtins/plugins/delegator-biscuit}/tests/biscuit_e2e.rs (98%) rename {crates/apl-delegator-oauth => builtins/plugins/delegator-oauth}/Cargo.toml (82%) rename {crates/apl-delegator-oauth => builtins/plugins/delegator-oauth}/src/config.rs (98%) rename {crates/apl-delegator-oauth => builtins/plugins/delegator-oauth}/src/delegator.rs (95%) rename {crates/apl-delegator-oauth => builtins/plugins/delegator-oauth}/src/factory.rs (96%) rename {crates/apl-delegator-oauth => builtins/plugins/delegator-oauth}/src/lib.rs (88%) rename {crates/apl-delegator-oauth => builtins/plugins/delegator-oauth}/tests/oauth_e2e.rs (99%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/Cargo.toml (77%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/src/claim_map.rs (99%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/src/config.rs (99%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/src/factory.rs (97%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/src/lib.rs (71%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/src/resolver.rs (97%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/src/trusted_issuer.rs (99%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/tests/jwks_url_e2e.rs (99%) rename {crates/apl-identity-jwt => builtins/plugins/identity-jwt}/tests/jwt_e2e.rs (98%) rename {crates/apl-pii-scanner => builtins/plugins/pii-scanner}/Cargo.toml (76%) rename {crates/apl-pii-scanner => builtins/plugins/pii-scanner}/src/config.rs (98%) rename {crates/apl-pii-scanner => builtins/plugins/pii-scanner}/src/factory.rs (94%) rename {crates/apl-pii-scanner => builtins/plugins/pii-scanner}/src/lib.rs (87%) rename {crates/apl-pii-scanner => builtins/plugins/pii-scanner}/src/scanner.rs (97%) rename {crates/apl-session-valkey => builtins/session/valkey}/Cargo.toml (87%) rename {crates/apl-session-valkey => builtins/session/valkey}/src/config.rs (99%) rename {crates/apl-session-valkey => builtins/session/valkey}/src/connection.rs (95%) rename {crates/apl-session-valkey => builtins/session/valkey}/src/error.rs (95%) rename {crates/apl-session-valkey => builtins/session/valkey}/src/factory.rs (95%) rename {crates/apl-session-valkey => builtins/session/valkey}/src/lib.rs (87%) rename {crates/apl-session-valkey => builtins/session/valkey}/src/store.rs (99%) rename {crates/apl-session-valkey => builtins/session/valkey}/tests/valkey_store_integration.rs (97%) delete mode 100644 crates/apl-cedarling/Cargo.toml delete mode 100644 crates/apl-cedarling/src/error.rs delete mode 100644 crates/apl-cedarling/src/identity/mod.rs delete mode 100644 crates/apl-cedarling/src/lib.rs delete mode 100644 crates/apl-cedarling/src/pdp/mod.rs delete mode 100644 crates/apl-cedarling/src/pdp/resolver.rs delete mode 100644 crates/apl-cedarling/tests/pdp_basic.rs create mode 100644 crates/cpex-builtins/Cargo.toml create mode 100644 crates/cpex-builtins/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ba2ae2..ce9bf664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,21 +17,29 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added -- APL (Attribute Policy Language) governance is now bundled into `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers the standard APL plugin/PDP factories (`validator/pii-scan`, `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and installs the APL config visitor on a manager. Call it after `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds the Cedarling-backed identity + PDP seams (off by default; the released `.a` stays lean). (#60) +- APL (Attribute Policy Language) governance is now bundled into `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers the standard APL plugin/PDP factories (`validator/pii-scan`, `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and installs the APL config visitor on a manager. Call it after `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use `PluginManager.EnableAPL()`. (#60) - Publish `libcpex_ffi.a` as signed GitHub Release artifacts on every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign keyless signatures + SHA256 checksums; see `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and-consume recipe. (#60) - FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` and panics on mismatch. Other language bindings must replicate the check. (#60) - CEL (Common Expression Language) policy decision backend. A new `apl-pdp-cel` crate registers `kind: cel`, letting authors write inline boolean predicates (`cel: { expr: ... }`) over the common attribute vocabulary (`subject.id`, `delegation.depth`, `session.labels`, ...), evaluated through the existing `PdpResolver` seam alongside Cedar, OPA, and AuthZen. Expressions compile once and cache by source; compile errors, undeclared-variable references, and non-boolean results fail closed (deny), overridable with `on_error: allow`. No change to APL evaluation semantics. (#68) - APL authoring ergonomics (backwards-compatible). The `apl:` wrapper is now optional — recognized APL terms (`policy`, `post_policy`, `args`, `result`, `pdp`, `session_store`) written directly on a section are honored, with the explicit `apl:` form still taking precedence. `run(name)` is accepted as an alias for `plugin(name)` in both policy steps and field pipelines. Unconditional `deny('reason')` / `deny('reason', 'code')` now parses as a bare action (e.g. in `on_deny:` lists), so a reason/code can be attached without a conditional. (#71) - Valkey-backed `SessionStore` for cross-node and cross-restart session label propagation. Selectable via a `kind: valkey` block under `global.apl.session_store` (factory pattern mirroring `pdp`), shipped in the `apl-session-valkey` crate and wired into `cpex-ffi` behind the optional `valkey` cargo feature (the default build and `.a` artifact are unaffected). Labels live in a Redis SET so appends are an atomic server-side union (`SADD`); the store is fail-closed (a load/append error denies the request rather than under-labeling), serves primary-only reads, supports an optional sliding TTL, requires TLS for non-localhost endpoints, and SHA-256s session ids out of the keyspace. When no block is configured the default remains the in-process memory store. See the operator runbook at `docs/operations/valkey-session-store.md`. (#74) - `cpex` host facade crate: a single dependency that re-exports the host runtime (`PluginManager`, `AplOptions`, `register_apl`) and the bundled plugin factories, each behind a cargo feature (`jwt`, `oauth`, `pii`, `audit`, `cedar`, `cel`, `valkey`). Hosts depend on `cpex` and enable the plugins they want instead of pinning `apl-cmf` / `apl-cpex` / `apl-pdp-*` / `apl-session-*` individually. `install_builtins(&mgr)` registers every enabled factory and installs the APL config visitor in one call; `register_builtin_plugins`, `builtin_pdp_factories`, and `builtin_session_store_factories` expose the pieces for hosts that assemble `AplOptions` themselves. (#77) +- `cpex-builtins` aggregator crate: the bundled extension set (plugins, PDPs, session stores) behind a 1:1 cargo-feature map, with a declarative `register_builtins!` macro that expands to explicit, `#[cfg]`-gated `register_factory` calls (kept explicit rather than `inventory`/`linkme` so factory symbols survive the linker GC inside `libcpex_ffi.a`). `register_builtins`, `builtin_pdps`, `builtin_session_store_factories`, and `install_builtins` are the single source of truth that both the `cpex` facade and `cpex-ffi` now delegate to. (#72) ### Changed +- The `cpex` facade is now **engine-only by default**: `cpex = "0.2"` compiles no builtin plugins. The bundled set is opt-in via the new `builtins` feature (the common in-process set) or `full` (everything, incl. Valkey), with the granular plugin features (`jwt`, `oauth`, `pii`, `audit`, `cedar`, `cel`, `valkey`) preserved as passthroughs. The registration helpers and concrete factory types are re-exported from `cpex-builtins` and appear only when a builtins feature is enabled. `cpex-ffi` keeps its prior bundled set (four hook plugins + `cedar-direct`) by selecting that exact `cpex-builtins` feature subset. No FFI ABI change. (#72) +- `PluginFactoryRegistry::register` now logs a `tracing::warn!` when a registration overwrites an existing `kind` (last-writer-wins is unchanged, but silent override was a footgun). (#72) +- Builtin extension crates moved out of the flat `crates/` directory into a `builtins/` tree (`builtins/plugins/`, `builtins/pdps/`, `builtins/session/`, `builtins/cedarling/`) and renamed off the `apl-` prefix, since they are CPEX plugins that *use* APL hooks rather than APL itself: `apl-pii-scanner` → `cpex-plugin-pii-scanner`, `apl-audit-logger` → `cpex-plugin-audit-logger`, `apl-identity-jwt` → `cpex-plugin-identity-jwt`, `apl-delegator-oauth` → `cpex-plugin-delegator-oauth`, `apl-delegator-biscuit` → `cpex-plugin-delegator-biscuit`, `apl-pdp-cedar-direct` → `cpex-pdp-cedar-direct`, `apl-pdp-cel` → `cpex-pdp-cel`, `apl-session-valkey` → `cpex-session-valkey`, `apl-cedarling` → `cpex-cedarling`. The policy crates (`apl-core`, `apl-cmf`, `apl-cpex`) keep their names. Config-facing `kind:` strings and the FFI C ABI are unchanged. (#72) - FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` extern C function and changed `cpex_load_config` to run registered config visitors (it now calls `load_config_yaml` internally so `apl:` blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped in lockstep. (#60) - Size-first `[profile.release]`: `opt-level = "z"`, `lto = true`, `codegen-units = 1`, `strip = true`. `libcpex_ffi.a` is linked statically into host binaries, so this flows straight into their image size — a representative statically-linked consumer shrank ~21%. `panic = "abort"` is intentionally not set (the FFI relies on `catch_unwind` at its `#[no_mangle]` boundary). No API or ABI change. (#69) - Trimmed the workspace `tokio` feature floor from `["full"]` to `["rt", "rt-multi-thread", "sync", "time", "macros"]` — the union of what the crates actually use; `reqwest`/`hyper` still pull `net`/`io` where they need them via feature unification. Drops the unused `fs`/`process`/`signal` surface (and the `signal-hook-registry` dependency). (#69) - `SessionStore` trait methods (`load_labels` / `append_labels`) now return `Result` so backend failures propagate to callers — the error channel fail-closed requires. `MemorySessionStore` is infallible and adapts trivially; the CMF invoker (`for_request` / `persist_session`) and the route handler propagate the error and fail the request closed on a load/append failure. This is part of the shared `SessionStore` contract that future bridges inherit. (#74) +### Removed + +- Removed the `cpex-cedarling` crate (a Sub-step A stub with no real Cedarling calls), its `cpex-ffi` optional dependency + `cedarling` cargo feature, and the `cedarling` PDP dialect from the APL grammar (`PdpDialect::Cedarling` and `cedarling:` step recognition). This drops the only `git` dependency in the workspace (the Janssen `cedarling` crate, ~200 transitive deps), making every crate publishable to crates.io. Cedarling was wired nowhere — no config, host, or Go binding referenced it — so there is no functional change; the remaining PDP `kind:` strings (`cedar-direct`, `cel`) and the FFI C ABI are unchanged. A `cedarling`-backed PDP can still be supplied out-of-tree (it degrades to `PdpDialect::Custom`, alongside the resolver-less `opa` / `authzen` / `nemo` dialects). + ### Fixed - Cedar evaluation no longer fails with "recursion limit reached" on hosts that give the FFI a small thread stack (notably musl, whose default is 128 KiB). `cedar-policy` aborts when `stacker::remaining_stack()` is below its 100 KiB floor; the cedar dispatch in `apl-pdp-cedar-direct` is now wrapped in `stacker::maybe_grow`, so it runs on an adequately sized stack regardless of the host (a no-op when there is already headroom, e.g. glibc's 8 MiB threads). Regression test exercises a real evaluation on a 128 KiB stack. (#69) diff --git a/Cargo.lock b/Cargo.lock index 80e2d5d2..4b42b362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,24 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -67,37 +49,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "apl-audit-logger" -version = "0.2.0" -dependencies = [ - "async-trait", - "chrono", - "cpex-core", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "apl-cedarling" -version = "0.2.0" -dependencies = [ - "apl-core", - "async-trait", - "cedar-policy", - "cedarling", - "cpex-core", - "serde", - "serde_json", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "apl-cmf" version = "0.2.0" @@ -142,138 +93,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "apl-delegator-biscuit" -version = "0.2.0" -dependencies = [ - "apl-core", - "async-trait", - "biscuit-auth", - "chrono", - "cpex-core", - "hex", - "serde", - "serde_json", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "apl-delegator-oauth" -version = "0.2.0" -dependencies = [ - "apl-core", - "async-trait", - "chrono", - "cpex-core", - "mockito", - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_urlencoded", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "apl-identity-jwt" -version = "0.2.0" -dependencies = [ - "apl-core", - "async-trait", - "base64 0.22.1", - "chrono", - "cpex-core", - "futures", - "jsonwebtoken 9.3.1", - "mockito", - "rand 0.8.6", - "reqwest 0.12.28", - "rsa", - "serde", - "serde_json", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "apl-pdp-cedar-direct" -version = "0.2.0" -dependencies = [ - "apl-cmf", - "apl-core", - "apl-cpex", - "async-trait", - "cedar-policy", - "cpex-core", - "futures", - "serde", - "serde_json", - "serde_yaml", - "stacker", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "apl-pdp-cel" -version = "0.2.0" -dependencies = [ - "apl-cmf", - "apl-core", - "apl-cpex", - "async-trait", - "cel", - "cpex-core", - "serde", - "serde_json", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "apl-pii-scanner" -version = "0.2.0" -dependencies = [ - "async-trait", - "cpex-core", - "regex", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "apl-session-valkey" -version = "0.2.0" -dependencies = [ - "apl-cpex", - "async-trait", - "deadpool-redis", - "redis", - "serde", - "serde_yaml", - "sha2 0.10.9", - "testcontainers", - "testcontainers-modules", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", -] - [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -298,12 +117,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - [[package]] name = "arrayvec" version = "0.5.2" @@ -401,28 +214,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.9" @@ -487,12 +278,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -587,9 +372,6 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] [[package]] name = "block-buffer" @@ -609,15 +391,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - [[package]] name = "bollard" version = "0.19.4" @@ -732,15 +505,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - [[package]] name = "cc" version = "1.2.62" @@ -748,8 +512,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -816,50 +578,6 @@ dependencies = [ "smol_str", ] -[[package]] -name = "cedarling" -version = "2.1.0" -source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" -dependencies = [ - "ahash", - "async-trait", - "base64 0.22.1", - "cedar-policy", - "cedar-policy-core", - "chrono", - "config", - "derive_more", - "flate2", - "futures", - "getrandom 0.2.17", - "getrandom 0.3.4", - "getrandom 0.4.2", - "gloo-timers", - "hdrhistogram", - "http_utils", - "jsonwebtoken 10.4.0", - "rand 0.10.1", - "reqwest 0.13.3", - "semver", - "serde", - "serde_json", - "serde_yaml_ng", - "smol_str", - "sparkv", - "strum", - "thiserror 2.0.18", - "time", - "tokio", - "tokio-util", - "typed-builder", - "url", - "uuid7", - "vfs", - "wasm-bindgen-futures", - "web-sys", - "zip", -] - [[package]] name = "cel" version = "0.13.0" @@ -888,17 +606,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.44" @@ -913,15 +620,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "colored" version = "3.1.1" @@ -954,86 +652,12 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "config" -version = "0.15.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" -dependencies = [ - "async-trait", - "convert_case 0.6.0", - "json5", - "pathdiff", - "ron", - "rust-ini", - "serde-untagged", - "serde_core", - "serde_json", - "toml", - "winnow", - "yaml-rust2", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -1054,17 +678,28 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "cpex" version = "0.2.0" dependencies = [ - "apl-audit-logger", "apl-cmf", "apl-core", "apl-cpex", - "apl-delegator-oauth", - "apl-identity-jwt", - "apl-pdp-cedar-direct", - "apl-pdp-cel", - "apl-pii-scanner", - "apl-session-valkey", + "cpex-builtins", + "cpex-core", +] + +[[package]] +name = "cpex-builtins" +version = "0.2.0" +dependencies = [ + "apl-core", + "apl-cpex", "cpex-core", + "cpex-pdp-cedar-direct", + "cpex-pdp-cel", + "cpex-plugin-audit-logger", + "cpex-plugin-delegator-oauth", + "cpex-plugin-identity-jwt", + "cpex-plugin-pii-scanner", + "cpex-session-valkey", + "tokio", ] [[package]] @@ -1104,15 +739,9 @@ dependencies = [ name = "cpex-ffi" version = "0.2.0" dependencies = [ - "apl-audit-logger", - "apl-cedarling", "apl-cpex", - "apl-delegator-oauth", - "apl-identity-jwt", - "apl-pdp-cedar-direct", - "apl-pii-scanner", - "apl-session-valkey", "async-trait", + "cpex-builtins", "cpex-core", "rmp-serde", "serde", @@ -1131,49 +760,168 @@ dependencies = [ ] [[package]] -name = "cpex-sdk" +name = "cpex-pdp-cedar-direct" version = "0.2.0" dependencies = [ + "apl-cmf", + "apl-core", + "apl-cpex", "async-trait", + "cedar-policy", "cpex-core", + "futures", "serde", "serde_json", + "serde_yaml", + "stacker", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +name = "cpex-pdp-cel" +version = "0.2.0" dependencies = [ - "libc", + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cel", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +name = "cpex-plugin-audit-logger" +version = "0.2.0" dependencies = [ - "libc", + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +name = "cpex-plugin-delegator-biscuit" +version = "0.2.0" dependencies = [ - "cfg-if", + "apl-core", + "async-trait", + "biscuit-auth", + "chrono", + "cpex-core", + "hex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "cpex-plugin-delegator-oauth" +version = "0.2.0" +dependencies = [ + "apl-core", + "async-trait", + "chrono", + "cpex-core", + "mockito", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "cpex-plugin-identity-jwt" +version = "0.2.0" +dependencies = [ + "apl-core", + "async-trait", + "base64 0.22.1", + "chrono", + "cpex-core", + "futures", + "jsonwebtoken", + "mockito", + "rand 0.8.6", + "reqwest", + "rsa", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-plugin-pii-scanner" +version = "0.2.0" +dependencies = [ + "async-trait", + "cpex-core", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-sdk" +version = "0.2.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "cpex-session-valkey" +version = "0.2.0" +dependencies = [ + "apl-cpex", + "async-trait", + "deadpool-redis", + "redis", + "serde", + "serde_yaml", + "sha2 0.10.9", + "testcontainers", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "crossbeam-utils", + "libc", ] [[package]] @@ -1182,12 +930,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1210,15 +952,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1226,7 +959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -1310,19 +1043,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - [[package]] name = "der" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" dependencies = [ - "const-oid 0.9.6", + "const-oid", ] [[package]] @@ -1331,7 +1058,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "const-oid", "pem-rfc7468", "zeroize", ] @@ -1346,29 +1073,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", - "unicode-xid", -] - [[package]] name = "digest" version = "0.9.0" @@ -1385,22 +1089,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", + "const-oid", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.2", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -1412,15 +1105,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - [[package]] name = "docker_credential" version = "1.4.0" @@ -1432,12 +1116,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -1514,7 +1192,6 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf", "pem-rfc7468", "pkcs8 0.10.2", "rand_core 0.6.4", @@ -1533,15 +1210,6 @@ dependencies = [ "log", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1568,17 +1236,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -1665,17 +1322,6 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1688,12 +1334,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1703,18 +1343,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "fstr" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b8f793a77bb6d48059953a3e9820fd860d19a9bed8164ed3572eb1981ec8aa" - [[package]] name = "futures" version = "0.3.32" @@ -1848,25 +1476,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", "wasip2", "wasip3", - "wasm-bindgen", -] - -[[package]] -name = "gloo-timers" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", ] [[package]] @@ -1905,12 +1518,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1919,16 +1526,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1937,29 +1535,6 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" -[[package]] -name = "hashlink" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "hdrhistogram" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" -dependencies = [ - "base64 0.21.7", - "byteorder", - "crossbeam-channel", - "flate2", - "nom", - "num-traits", -] - [[package]] name = "heck" version = "0.5.0" @@ -1978,15 +1553,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" version = "0.12.1" @@ -2038,17 +1604,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http_utils" -version = "0.1.0" -source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" -dependencies = [ - "reqwest 0.13.3", - "serde", - "thiserror 2.0.18", - "tokio", -] - [[package]] name = "httparse" version = "1.10.1" @@ -2061,15 +1616,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.9.0" @@ -2154,11 +1700,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -2368,65 +1912,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.117", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.95" @@ -2439,17 +1924,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -2465,37 +1939,13 @@ dependencies = [ "simple_asn1", ] -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "base64 0.22.1", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac", - "js-sys", - "p256", - "p384", - "pem", - "rand 0.8.6", - "rsa", - "serde", - "serde_json", - "sha2 0.10.9", - "signature", - "simple_asn1", - "zeroize", -] - [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -2545,12 +1995,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - [[package]] name = "libc" version = "0.2.184" @@ -2646,15 +2090,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rust2" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e9ceaec84b54518262de7cf06b8b43e83c808349960f1610b21b0bfc9640f20" -dependencies = [ - "sha2 0.11.0", -] - [[package]] name = "matchit" version = "0.8.4" @@ -2702,16 +2137,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.0" @@ -2915,16 +2340,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "p256" version = "0.13.2" @@ -2937,18 +2352,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - [[package]] name = "parking" version = "2.2.1" @@ -3009,12 +2412,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pem" version = "3.0.6" @@ -3040,49 +2437,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2 0.10.9", -] - [[package]] name = "petgraph" version = "0.7.1" @@ -3165,12 +2519,6 @@ dependencies = [ "spki 0.7.3", ] -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "portable-atomic" version = "1.13.1" @@ -3192,12 +2540,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppmd-rust" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3375,7 +2717,6 @@ version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3447,17 +2788,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -3496,12 +2826,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redis" version = "1.2.3" @@ -3593,51 +2917,11 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", @@ -3646,13 +2930,11 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -3666,6 +2948,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -3711,27 +2994,13 @@ dependencies = [ "serde", ] -[[package]] -name = "ron" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" -dependencies = [ - "bitflags", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - [[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", + "const-oid", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -3745,16 +3014,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3795,7 +3054,6 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -3836,40 +3094,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3957,7 +3187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -3978,10 +3208,6 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "serde" @@ -3993,18 +3219,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - [[package]] name = "serde_bytes" version = "0.11.19" @@ -4060,15 +3274,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4126,19 +3331,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "serde_yaml_ng" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serdect" version = "0.2.0" @@ -4157,7 +3349,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.9.0", "opaque-debug", ] @@ -4169,21 +3361,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.10.7", ] -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - [[package]] name = "sha3" version = "0.10.9" @@ -4210,28 +3391,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "similar" version = "2.7.0" @@ -4288,15 +3447,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "sparkv" -version = "0.1.1" -source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" -dependencies = [ - "chrono", - "thiserror 2.0.18", -] - [[package]] name = "spin" version = "0.9.8" @@ -4382,27 +3532,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4451,27 +3580,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "term" version = "1.2.1" @@ -4567,7 +3675,6 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -4591,15 +3698,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -4687,37 +3785,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - [[package]] name = "tonic" version = "0.14.6" @@ -4850,50 +3917,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" -[[package]] -name = "typed-builder" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" -dependencies = [ - "typed-builder-macro", -] - -[[package]] -name = "typed-builder-macro" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "ulid" version = "1.2.1" @@ -4935,12 +3964,6 @@ dependencies = [ "unicode-script", ] -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - [[package]] name = "unicode-width" version = "0.1.14" @@ -5035,34 +4058,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "uuid7" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f14c93e6dd46ded457afc647964ac685427f9f001815d07ba30398cb79d9c9ce" -dependencies = [ - "fstr", - "rand_core 0.10.1", - "rand_core 0.6.4", - "serde", - "uuid", -] - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vfs" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e723b9e1c02a3cf9f9d0de6a4ddb8cdc1df859078902fe0ae0589d615711ae6" -dependencies = [ - "filetime", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -5215,15 +4216,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -5311,17 +4303,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -5505,15 +4486,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5624,17 +4596,6 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "yaml-rust2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink", -] - [[package]] name = "yoke" version = "0.8.2" @@ -5752,74 +4713,8 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "8.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72" -dependencies = [ - "bzip2", - "crc32fast", - "deflate64", - "flate2", - "indexmap 2.14.0", - "lzma-rust2", - "memchr", - "ppmd-rust", - "time", - "typed-path", - "zopfli", - "zstd", -] - -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index 2c5fbdd9..9865edda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,48 +12,48 @@ members = [ "crates/cpex-core", "crates/cpex-orchestration", "crates/cpex-sdk", + "crates/cpex-builtins", "crates/cpex-ffi", "crates/apl-core", "crates/apl-cmf", "crates/apl-cpex", - "crates/apl-pdp-cedar-direct", - "crates/apl-pdp-cel", - "crates/apl-cedarling", - "crates/apl-identity-jwt", - "crates/apl-delegator-oauth", - "crates/apl-delegator-biscuit", - "crates/apl-pii-scanner", - "crates/apl-audit-logger", - "crates/apl-session-valkey", + "builtins/plugins/pii-scanner", + "builtins/plugins/audit-logger", + "builtins/plugins/identity-jwt", + "builtins/plugins/delegator-oauth", + "builtins/plugins/delegator-biscuit", + "builtins/pdps/cedar-direct", + "builtins/pdps/cel", + "builtins/session/valkey", "examples/go-demo/ffi", ] # `default-members` controls what `cargo build` / `cargo test` (with no -# `-p` or `--workspace` flag) picks up. Cedarling integration crates -# pull ~200 transitive deps (jsonwebtoken, reqwest, sparkv, datalogic-rs, -# flate2, etc.) and slow default builds significantly — excluding them +# `-p` or `--workspace` flag) picks up. `cpex-session-valkey` pulls a redis +# client + a rustls TLS stack, which slows default builds — excluding it # from default-members keeps everyday iteration fast. # -# To exercise Cedarling crates: -# cargo build --workspace # all members -# cargo build -p apl-cedarling # just this one -# cargo test --workspace # full sweep (CI) +# To exercise the Valkey session store: +# cargo build --workspace # all members +# cargo build -p cpex-session-valkey # just this one +# cargo test --workspace # full sweep (CI) default-members = [ "crates/cpex", "crates/cpex-core", "crates/cpex-orchestration", "crates/cpex-sdk", + "crates/cpex-builtins", "crates/cpex-ffi", "crates/apl-core", "crates/apl-cmf", "crates/apl-cpex", - "crates/apl-pdp-cedar-direct", - "crates/apl-pdp-cel", - "crates/apl-identity-jwt", - "crates/apl-delegator-oauth", - "crates/apl-delegator-biscuit", - "crates/apl-pii-scanner", - "crates/apl-audit-logger", + "builtins/plugins/pii-scanner", + "builtins/plugins/audit-logger", + "builtins/plugins/identity-jwt", + "builtins/plugins/delegator-oauth", + "builtins/plugins/delegator-biscuit", + "builtins/pdps/cedar-direct", + "builtins/pdps/cel", "examples/go-demo/ffi", ] diff --git a/crates/apl-pdp-cedar-direct/Cargo.toml b/builtins/pdps/cedar-direct/Cargo.toml similarity index 58% rename from crates/apl-pdp-cedar-direct/Cargo.toml rename to builtins/pdps/cedar-direct/Cargo.toml index 15c7bcc3..46108461 100644 --- a/crates/apl-pdp-cedar-direct/Cargo.toml +++ b/builtins/pdps/cedar-direct/Cargo.toml @@ -1,43 +1,27 @@ -# Location: ./crates/apl-pdp-cedar-direct/Cargo.toml +# Location: ./builtins/pdps/cedar-direct/Cargo.toml # Copyright 2025 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-pdp-cedar-direct — a `PdpResolver` implementation that wraps the bare +# 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). # -# When to use this crate vs `apl-pdp-cedarling`: -# -# - **cedar-direct** — host already has identity validated (via gateway, -# SPIFFE, prior plugin, or hand-rolled JWT validation); policies are -# loaded as text/files at startup and don't change at runtime; smallest -# dep tree; ~5 transitive crates instead of 200+. -# - **cedarling** — host wants JWT validation + claims-to-entity mapping -# + centralized policy management (Janssen Lock Server) all in one -# library. -# -# Both crates speak Cedar 4.x; their decisions on identical policy + entity -# + request inputs are byte-identical. The difference is what's around the -# Cedar engine. +# 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 = "apl-pdp-cedar-direct" +name = "cpex-pdp-cedar-direct" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -apl-core = { path = "../apl-core" } +apl-core = { path = "../../../crates/apl-core" } # Permissive caret spec — `"4"` means "any 4.x that Cargo can find." -# We rely on Cargo's standard version resolution to dedup with -# cedarling's `cedar-policy = "4.9.0"` (also caret), so both crates -# end up compiling against the same `cedar-policy` version (currently -# 4.11 — bumps automatically when either side allows a newer 4.x). -# This matters because mixing `cedar_policy@4.9::Decision` and -# `cedar_policy@4.11::Decision` in the same workspace would produce -# distinct types Rust treats as incompatible. # # Code-side note: we use `Request::new(...)` (added in 4.11 alongside # the deprecated builder; still available in older 4.x via the @@ -64,9 +48,9 @@ tracing = { workspace = true } # 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 = { path = "../apl-cmf" } -apl-cpex = { path = "../apl-cpex" } -cpex-core = { path = "../cpex-core" } +apl-cmf = { path = "../../../crates/apl-cmf" } +apl-cpex = { path = "../../../crates/apl-cpex" } +cpex-core = { path = "../../../crates/cpex-core" } 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 diff --git a/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs b/builtins/pdps/cedar-direct/src/cedar_attrs.rs similarity index 97% rename from crates/apl-pdp-cedar-direct/src/cedar_attrs.rs rename to builtins/pdps/cedar-direct/src/cedar_attrs.rs index ad91dd76..b0a24b21 100644 --- a/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs +++ b/builtins/pdps/cedar-direct/src/cedar_attrs.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/cedar_attrs.rs +// Location: ./builtins/pdps/cedar-direct/src/cedar_attrs.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/decision.rs b/builtins/pdps/cedar-direct/src/decision.rs similarity index 98% rename from crates/apl-pdp-cedar-direct/src/decision.rs rename to builtins/pdps/cedar-direct/src/decision.rs index 4391f98c..d683abf2 100644 --- a/crates/apl-pdp-cedar-direct/src/decision.rs +++ b/builtins/pdps/cedar-direct/src/decision.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/decision.rs +// Location: ./builtins/pdps/cedar-direct/src/decision.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/entities.rs b/builtins/pdps/cedar-direct/src/entities.rs similarity index 99% rename from crates/apl-pdp-cedar-direct/src/entities.rs rename to builtins/pdps/cedar-direct/src/entities.rs index 3ae3cfcc..73cd526a 100644 --- a/crates/apl-pdp-cedar-direct/src/entities.rs +++ b/builtins/pdps/cedar-direct/src/entities.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/entities.rs +// Location: ./builtins/pdps/cedar-direct/src/entities.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/error.rs b/builtins/pdps/cedar-direct/src/error.rs similarity index 97% rename from crates/apl-pdp-cedar-direct/src/error.rs rename to builtins/pdps/cedar-direct/src/error.rs index 3b640fc2..eba02858 100644 --- a/crates/apl-pdp-cedar-direct/src/error.rs +++ b/builtins/pdps/cedar-direct/src/error.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/error.rs +// Location: ./builtins/pdps/cedar-direct/src/error.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/factory.rs b/builtins/pdps/cedar-direct/src/factory.rs similarity index 96% rename from crates/apl-pdp-cedar-direct/src/factory.rs rename to builtins/pdps/cedar-direct/src/factory.rs index dd5c4ba3..51025aff 100644 --- a/crates/apl-pdp-cedar-direct/src/factory.rs +++ b/builtins/pdps/cedar-direct/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/factory.rs +// Location: ./builtins/pdps/cedar-direct/src/factory.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/lib.rs b/builtins/pdps/cedar-direct/src/lib.rs similarity index 96% rename from crates/apl-pdp-cedar-direct/src/lib.rs rename to builtins/pdps/cedar-direct/src/lib.rs index 606576aa..c38c1e8a 100644 --- a/crates/apl-pdp-cedar-direct/src/lib.rs +++ b/builtins/pdps/cedar-direct/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/lib.rs +// Location: ./builtins/pdps/cedar-direct/src/lib.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-pdp-cedar-direct — `PdpResolver` over the bare `cedar-policy` crate. +// cpex-pdp-cedar-direct — `PdpResolver` over the bare `cedar-policy` crate. // // # Where this lives in the stack // diff --git a/crates/apl-pdp-cedar-direct/src/request.rs b/builtins/pdps/cedar-direct/src/request.rs similarity index 99% rename from crates/apl-pdp-cedar-direct/src/request.rs rename to builtins/pdps/cedar-direct/src/request.rs index 4c952aed..c4cfb1be 100644 --- a/crates/apl-pdp-cedar-direct/src/request.rs +++ b/builtins/pdps/cedar-direct/src/request.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/request.rs +// Location: ./builtins/pdps/cedar-direct/src/request.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/resolver.rs b/builtins/pdps/cedar-direct/src/resolver.rs similarity index 99% rename from crates/apl-pdp-cedar-direct/src/resolver.rs rename to builtins/pdps/cedar-direct/src/resolver.rs index ba099132..392b1bd1 100644 --- a/crates/apl-pdp-cedar-direct/src/resolver.rs +++ b/builtins/pdps/cedar-direct/src/resolver.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/resolver.rs +// Location: ./builtins/pdps/cedar-direct/src/resolver.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/src/template.rs b/builtins/pdps/cedar-direct/src/template.rs similarity index 99% rename from crates/apl-pdp-cedar-direct/src/template.rs rename to builtins/pdps/cedar-direct/src/template.rs index 1479661e..5466e287 100644 --- a/crates/apl-pdp-cedar-direct/src/template.rs +++ b/builtins/pdps/cedar-direct/src/template.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/src/template.rs +// Location: ./builtins/pdps/cedar-direct/src/template.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs b/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs similarity index 98% rename from crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs rename to builtins/pdps/cedar-direct/tests/basic_allow_deny.rs index 05400a4b..a6098d31 100644 --- a/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs +++ b/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs +// Location: ./builtins/pdps/cedar-direct/tests/basic_allow_deny.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -12,7 +12,7 @@ use apl_core::attributes::AttributeBag; use apl_core::evaluator::Decision; use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; -use apl_pdp_cedar_direct::CedarDirectResolver; +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 diff --git a/crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs b/builtins/pdps/cedar-direct/tests/small_stack_eval.rs similarity index 96% rename from crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs rename to builtins/pdps/cedar-direct/tests/small_stack_eval.rs index 92b2ddd0..40c25f65 100644 --- a/crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs +++ b/builtins/pdps/cedar-direct/tests/small_stack_eval.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs +// Location: ./builtins/pdps/cedar-direct/tests/small_stack_eval.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -28,7 +28,7 @@ use apl_core::attributes::AttributeBag; use apl_core::evaluator::Decision; use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; -use apl_pdp_cedar_direct::CedarDirectResolver; +use cpex_pdp_cedar_direct::CedarDirectResolver; /// musl's default thread stack size — below cedar's 100 KiB remaining-stack /// floor once evaluation is underway. diff --git a/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs b/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs similarity index 98% rename from crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs rename to builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs index 94346396..953747f7 100644 --- a/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs +++ b/builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs +// Location: ./builtins/pdps/cedar-direct/tests/visitor_pdp_config.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -32,7 +32,7 @@ use cpex_core::hooks::payload::Extensions; use cpex_core::manager::PluginManager; use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; -use apl_pdp_cedar_direct::CedarDirectPdpFactory; +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 diff --git a/crates/apl-pdp-cel/Cargo.toml b/builtins/pdps/cel/Cargo.toml similarity index 86% rename from crates/apl-pdp-cel/Cargo.toml rename to builtins/pdps/cel/Cargo.toml index 61f618e2..2dbce9c9 100644 --- a/crates/apl-pdp-cel/Cargo.toml +++ b/builtins/pdps/cel/Cargo.toml @@ -1,9 +1,9 @@ -# Location: ./crates/apl-pdp-cel/Cargo.toml +# Location: ./builtins/pdps/cel/Cargo.toml # Copyright 2026 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-pdp-cel — a `PdpResolver` that evaluates CEL (Common Expression +# cpex-pdp-cel — a `PdpResolver` that evaluates CEL (Common Expression # Language) boolean predicates against the policy `AttributeBag`, authored # inline in route YAML (`cel: { expr: "..." }`). # @@ -13,14 +13,14 @@ # synchronous and side-effect-free. [package] -name = "apl-pdp-cel" +name = "cpex-pdp-cel" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -apl-core = { path = "../apl-core" } +apl-core = { path = "../../../crates/apl-core" } # 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 @@ -51,7 +51,7 @@ tracing = { workspace = true } # 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 = { path = "../apl-cmf" } -apl-cpex = { path = "../apl-cpex" } -cpex-core = { path = "../cpex-core" } +apl-cmf = { path = "../../../crates/apl-cmf" } +apl-cpex = { path = "../../../crates/apl-cpex" } +cpex-core = { path = "../../../crates/cpex-core" } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pdp-cel/src/activation.rs b/builtins/pdps/cel/src/activation.rs similarity index 99% rename from crates/apl-pdp-cel/src/activation.rs rename to builtins/pdps/cel/src/activation.rs index 488296a9..e7b5d851 100644 --- a/crates/apl-pdp-cel/src/activation.rs +++ b/builtins/pdps/cel/src/activation.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cel/src/activation.rs +// Location: ./builtins/pdps/cel/src/activation.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cel/src/error.rs b/builtins/pdps/cel/src/error.rs similarity index 96% rename from crates/apl-pdp-cel/src/error.rs rename to builtins/pdps/cel/src/error.rs index 2b8e52b1..844905cb 100644 --- a/crates/apl-pdp-cel/src/error.rs +++ b/builtins/pdps/cel/src/error.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cel/src/error.rs +// Location: ./builtins/pdps/cel/src/error.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cel/src/factory.rs b/builtins/pdps/cel/src/factory.rs similarity index 96% rename from crates/apl-pdp-cel/src/factory.rs rename to builtins/pdps/cel/src/factory.rs index caa26a45..196d4238 100644 --- a/crates/apl-pdp-cel/src/factory.rs +++ b/builtins/pdps/cel/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cel/src/factory.rs +// Location: ./builtins/pdps/cel/src/factory.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pdp-cel/src/lib.rs b/builtins/pdps/cel/src/lib.rs similarity index 96% rename from crates/apl-pdp-cel/src/lib.rs rename to builtins/pdps/cel/src/lib.rs index 544a0e8a..5c697e63 100644 --- a/crates/apl-pdp-cel/src/lib.rs +++ b/builtins/pdps/cel/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-pdp-cel/src/lib.rs +// Location: ./builtins/pdps/cel/src/lib.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-pdp-cel — `PdpResolver` over the `cel` (Common Expression Language) +// cpex-pdp-cel — `PdpResolver` over the `cel` (Common Expression Language) // interpreter. // // # Where this lives in the stack @@ -93,7 +93,7 @@ // 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 / cedarling / opa** when policy +// 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. diff --git a/crates/apl-pdp-cel/src/resolver.rs b/builtins/pdps/cel/src/resolver.rs similarity index 99% rename from crates/apl-pdp-cel/src/resolver.rs rename to builtins/pdps/cel/src/resolver.rs index 4115b286..240f6559 100644 --- a/crates/apl-pdp-cel/src/resolver.rs +++ b/builtins/pdps/cel/src/resolver.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cel/src/resolver.rs +// Location: ./builtins/pdps/cel/src/resolver.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -151,7 +151,7 @@ impl CelResolver { /// /// ```rust,ignore /// use std::sync::Arc; - /// use apl_pdp_cel::CelResolver; + /// use cpex_pdp_cel::CelResolver; /// /// let resolver = CelResolver::new().with_functions(|ctx| { /// // Regex helper — authors can write `args.path.matches_prefix("/api/")`. diff --git a/crates/apl-pdp-cel/tests/visitor_cel_config.rs b/builtins/pdps/cel/tests/visitor_cel_config.rs similarity index 99% rename from crates/apl-pdp-cel/tests/visitor_cel_config.rs rename to builtins/pdps/cel/tests/visitor_cel_config.rs index 89b72c80..ae0cc964 100644 --- a/crates/apl-pdp-cel/tests/visitor_cel_config.rs +++ b/builtins/pdps/cel/tests/visitor_cel_config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pdp-cel/tests/visitor_cel_config.rs +// Location: ./builtins/pdps/cel/tests/visitor_cel_config.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -30,7 +30,7 @@ use cpex_core::hooks::payload::Extensions; use cpex_core::manager::PluginManager; use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; -use apl_pdp_cel::CelPdpFactory; +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 diff --git a/crates/apl-audit-logger/Cargo.toml b/builtins/plugins/audit-logger/Cargo.toml similarity index 77% rename from crates/apl-audit-logger/Cargo.toml rename to builtins/plugins/audit-logger/Cargo.toml index ad729e06..451dcaf0 100644 --- a/crates/apl-audit-logger/Cargo.toml +++ b/builtins/plugins/audit-logger/Cargo.toml @@ -1,22 +1,22 @@ -# Location: ./crates/apl-audit-logger/Cargo.toml +# Location: ./builtins/plugins/audit-logger/Cargo.toml # Copyright 2026 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-audit-logger — CMF plugin that emits a structured audit +# 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 = "apl-audit-logger" +name = "cpex-plugin-audit-logger" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -cpex-core = { path = "../cpex-core" } +cpex-core = { path = "../../../crates/cpex-core" } async-trait = { workspace = true } chrono = { workspace = true } diff --git a/crates/apl-audit-logger/src/config.rs b/builtins/plugins/audit-logger/src/config.rs similarity index 94% rename from crates/apl-audit-logger/src/config.rs rename to builtins/plugins/audit-logger/src/config.rs index 168750b1..577ba516 100644 --- a/crates/apl-audit-logger/src/config.rs +++ b/builtins/plugins/audit-logger/src/config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-audit-logger/src/config.rs +// Location: ./builtins/plugins/audit-logger/src/config.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-audit-logger/src/factory.rs b/builtins/plugins/audit-logger/src/factory.rs similarity index 91% rename from crates/apl-audit-logger/src/factory.rs rename to builtins/plugins/audit-logger/src/factory.rs index 05eb90fd..38fa039d 100644 --- a/crates/apl-audit-logger/src/factory.rs +++ b/builtins/plugins/audit-logger/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-audit-logger/src/factory.rs +// Location: ./builtins/plugins/audit-logger/src/factory.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -28,7 +28,7 @@ impl PluginFactory for AuditLoggerFactory { if config.hooks.is_empty() { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-audit-logger): `hooks:` must list at \ + "plugin '{}' (cpex-plugin-audit-logger): `hooks:` must list at \ least one CMF hook to audit (e.g. cmf.tool_pre_invoke)", config.name ), diff --git a/crates/apl-audit-logger/src/lib.rs b/builtins/plugins/audit-logger/src/lib.rs similarity index 91% rename from crates/apl-audit-logger/src/lib.rs rename to builtins/plugins/audit-logger/src/lib.rs index 5671c372..a980bdc7 100644 --- a/crates/apl-audit-logger/src/lib.rs +++ b/builtins/plugins/audit-logger/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-audit-logger/src/lib.rs +// Location: ./builtins/plugins/audit-logger/src/lib.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-audit-logger — CMF plugin that emits one structured JSON +// cpex-plugin-audit-logger — CMF plugin that emits one structured JSON // audit record per dispatched request. The record captures: // // * timestamp + correlation id diff --git a/crates/apl-audit-logger/src/logger.rs b/builtins/plugins/audit-logger/src/logger.rs similarity index 98% rename from crates/apl-audit-logger/src/logger.rs rename to builtins/plugins/audit-logger/src/logger.rs index 7f6404d7..b02b720f 100644 --- a/crates/apl-audit-logger/src/logger.rs +++ b/builtins/plugins/audit-logger/src/logger.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-audit-logger/src/logger.rs +// Location: ./builtins/plugins/audit-logger/src/logger.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -32,7 +32,7 @@ impl AuditLogger { Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-audit-logger) config parse failed: {e}", + "plugin '{}' (cpex-plugin-audit-logger) config parse failed: {e}", cfg.name ), }) diff --git a/crates/apl-delegator-biscuit/Cargo.toml b/builtins/plugins/delegator-biscuit/Cargo.toml similarity index 80% rename from crates/apl-delegator-biscuit/Cargo.toml rename to builtins/plugins/delegator-biscuit/Cargo.toml index f040f614..06d33507 100644 --- a/crates/apl-delegator-biscuit/Cargo.toml +++ b/builtins/plugins/delegator-biscuit/Cargo.toml @@ -1,9 +1,9 @@ -# Location: ./crates/apl-delegator-biscuit/Cargo.toml +# Location: ./builtins/plugins/delegator-biscuit/Cargo.toml # Copyright 2025 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-delegator-biscuit — `TokenDelegateHandler` that performs +# cpex-plugin-delegator-biscuit — `TokenDelegateHandler` that performs # biscuit-auth capability-token attenuation. # # # Why this exists @@ -24,26 +24,26 @@ # inbound biscuit; completion blocks land in a future post-result # audit hook. # -# # When to reach for this vs `apl-delegator-oauth` +# # When to reach for this vs `cpex-plugin-delegator-oauth` # -# - **`apl-delegator-biscuit`** — capability tokens, cryptographic +# - **`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. -# - **`apl-delegator-oauth`** (slice 6) — RFC 8693 against an +# - **`cpex-plugin-delegator-oauth`** (slice 6) — RFC 8693 against an # OAuth IdP. Use when centralized audit/revocation matters more # than roundtrip cost. [package] -name = "apl-delegator-biscuit" +name = "cpex-plugin-delegator-biscuit" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -apl-core = { path = "../apl-core" } -cpex-core = { path = "../cpex-core" } +apl-core = { path = "../../../crates/apl-core" } +cpex-core = { path = "../../../crates/cpex-core" } # biscuit-auth v6 — current major. Maintained by Clever Cloud + # community. Ed25519 + Datalog. No default-features off needed; the diff --git a/crates/apl-delegator-biscuit/src/config.rs b/builtins/plugins/delegator-biscuit/src/config.rs similarity index 98% rename from crates/apl-delegator-biscuit/src/config.rs rename to builtins/plugins/delegator-biscuit/src/config.rs index 16c3ac0d..742014cb 100644 --- a/crates/apl-delegator-biscuit/src/config.rs +++ b/builtins/plugins/delegator-biscuit/src/config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-biscuit/src/config.rs +// Location: ./builtins/plugins/delegator-biscuit/src/config.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-delegator-biscuit/src/delegator.rs b/builtins/plugins/delegator-biscuit/src/delegator.rs similarity index 96% rename from crates/apl-delegator-biscuit/src/delegator.rs rename to builtins/plugins/delegator-biscuit/src/delegator.rs index 9961886d..b1fdfa9e 100644 --- a/crates/apl-delegator-biscuit/src/delegator.rs +++ b/builtins/plugins/delegator-biscuit/src/delegator.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-biscuit/src/delegator.rs +// Location: ./builtins/plugins/delegator-biscuit/src/delegator.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -77,7 +77,7 @@ impl BiscuitDelegator { let raw = cfg.config.as_ref().ok_or_else(|| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-biscuit) requires a `config:` block", + "plugin '{}' (cpex-plugin-delegator-biscuit) requires a `config:` block", cfg.name ), }) @@ -86,7 +86,7 @@ impl BiscuitDelegator { .map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-biscuit) config parse failed: {e}", + "plugin '{}' (cpex-plugin-delegator-biscuit) config parse failed: {e}", cfg.name ), }) @@ -95,7 +95,7 @@ impl BiscuitDelegator { let root_public_key = typed.root_public_key.resolve().map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-biscuit) root_public_key: {e}", + "plugin '{}' (cpex-plugin-delegator-biscuit) root_public_key: {e}", cfg.name ), }) diff --git a/crates/apl-delegator-biscuit/src/lib.rs b/builtins/plugins/delegator-biscuit/src/lib.rs similarity index 89% rename from crates/apl-delegator-biscuit/src/lib.rs rename to builtins/plugins/delegator-biscuit/src/lib.rs index fa5b3e9a..ab1529e7 100644 --- a/crates/apl-delegator-biscuit/src/lib.rs +++ b/builtins/plugins/delegator-biscuit/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-delegator-biscuit/src/lib.rs +// Location: ./builtins/plugins/delegator-biscuit/src/lib.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-delegator-biscuit — `TokenDelegateHandler` backed by biscuit +// cpex-plugin-delegator-biscuit — `TokenDelegateHandler` backed by biscuit // capability-token attenuation. // // The host registers this against `token.delegate`; outbound diff --git a/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs b/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs similarity index 98% rename from crates/apl-delegator-biscuit/tests/biscuit_e2e.rs rename to builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs index 7230da62..1989b64e 100644 --- a/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs +++ b/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-biscuit/tests/biscuit_e2e.rs +// Location: ./builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -26,7 +26,7 @@ use cpex_core::hooks::payload::Extensions; use cpex_core::manager::PluginManager; use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; -use apl_delegator_biscuit::BiscuitDelegator; +use cpex_plugin_delegator_biscuit::BiscuitDelegator; use serde_json::json; diff --git a/crates/apl-delegator-oauth/Cargo.toml b/builtins/plugins/delegator-oauth/Cargo.toml similarity index 82% rename from crates/apl-delegator-oauth/Cargo.toml rename to builtins/plugins/delegator-oauth/Cargo.toml index f4956282..a83c457e 100644 --- a/crates/apl-delegator-oauth/Cargo.toml +++ b/builtins/plugins/delegator-oauth/Cargo.toml @@ -1,9 +1,9 @@ -# Location: ./crates/apl-delegator-oauth/Cargo.toml +# Location: ./builtins/plugins/delegator-oauth/Cargo.toml # Copyright 2025 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-delegator-oauth — `TokenDelegateHandler` that performs RFC 8693 +# cpex-plugin-delegator-oauth — `TokenDelegateHandler` that performs RFC 8693 # OAuth 2.0 token exchange against any compliant IdP. # # # Why this exists @@ -15,14 +15,14 @@ # 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 `apl-delegator-biscuit` +# # When to reach for this vs `cpex-plugin-delegator-biscuit` # -# - **`apl-delegator-oauth`** (this crate) — IdP-mediated. Use when +# - **`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. -# - **`apl-delegator-biscuit`** (slice 7) — decentralized capability +# - **`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. # @@ -30,15 +30,15 @@ # swappable at config time. [package] -name = "apl-delegator-oauth" +name = "cpex-plugin-delegator-oauth" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -apl-core = { path = "../apl-core" } -cpex-core = { path = "../cpex-core" } +apl-core = { path = "../../../crates/apl-core" } +cpex-core = { path = "../../../crates/cpex-core" } # `reqwest` for the HTTP POST to the IdP token endpoint. Default # features pull `rustls` for TLS — we explicitly disable the diff --git a/crates/apl-delegator-oauth/src/config.rs b/builtins/plugins/delegator-oauth/src/config.rs similarity index 98% rename from crates/apl-delegator-oauth/src/config.rs rename to builtins/plugins/delegator-oauth/src/config.rs index 0a4856b2..d7aa9a22 100644 --- a/crates/apl-delegator-oauth/src/config.rs +++ b/builtins/plugins/delegator-oauth/src/config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-oauth/src/config.rs +// Location: ./builtins/plugins/delegator-oauth/src/config.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-delegator-oauth/src/delegator.rs b/builtins/plugins/delegator-oauth/src/delegator.rs similarity index 95% rename from crates/apl-delegator-oauth/src/delegator.rs rename to builtins/plugins/delegator-oauth/src/delegator.rs index 09508483..f95f7bc9 100644 --- a/crates/apl-delegator-oauth/src/delegator.rs +++ b/builtins/plugins/delegator-oauth/src/delegator.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-oauth/src/delegator.rs +// Location: ./builtins/plugins/delegator-oauth/src/delegator.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -97,7 +97,7 @@ impl OAuthDelegator { let raw = cfg.config.as_ref().ok_or_else(|| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth) requires a `config:` block", + "plugin '{}' (cpex-plugin-delegator-oauth) requires a `config:` block", cfg.name ), }) @@ -106,7 +106,7 @@ impl OAuthDelegator { .map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth) config parse failed: {e}", + "plugin '{}' (cpex-plugin-delegator-oauth) config parse failed: {e}", cfg.name ), }) @@ -115,7 +115,7 @@ impl OAuthDelegator { if typed.token_endpoint.trim().is_empty() { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth): token_endpoint must be non-empty", + "plugin '{}' (cpex-plugin-delegator-oauth): token_endpoint must be non-empty", cfg.name ), })); @@ -128,7 +128,7 @@ impl OAuthDelegator { if let Err(e) = require_https(&typed.token_endpoint, typed.insecure_http) { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth): token_endpoint {e}", + "plugin '{}' (cpex-plugin-delegator-oauth): token_endpoint {e}", cfg.name, ), })); @@ -136,7 +136,7 @@ impl OAuthDelegator { if typed.client_id.trim().is_empty() { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth): client_id must be non-empty", + "plugin '{}' (cpex-plugin-delegator-oauth): client_id must be non-empty", cfg.name ), })); @@ -145,7 +145,7 @@ impl OAuthDelegator { let secret = typed.client_secret_source.resolve().map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth) client secret resolve failed: {e}", + "plugin '{}' (cpex-plugin-delegator-oauth) client secret resolve failed: {e}", cfg.name ), }) @@ -157,7 +157,7 @@ impl OAuthDelegator { .map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-delegator-oauth) HTTP client build failed: {e}", + "plugin '{}' (cpex-plugin-delegator-oauth) HTTP client build failed: {e}", cfg.name ), }) diff --git a/crates/apl-delegator-oauth/src/factory.rs b/builtins/plugins/delegator-oauth/src/factory.rs similarity index 96% rename from crates/apl-delegator-oauth/src/factory.rs rename to builtins/plugins/delegator-oauth/src/factory.rs index b6a6c167..128f738a 100644 --- a/crates/apl-delegator-oauth/src/factory.rs +++ b/builtins/plugins/delegator-oauth/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-oauth/src/factory.rs +// Location: ./builtins/plugins/delegator-oauth/src/factory.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-delegator-oauth/src/lib.rs b/builtins/plugins/delegator-oauth/src/lib.rs similarity index 88% rename from crates/apl-delegator-oauth/src/lib.rs rename to builtins/plugins/delegator-oauth/src/lib.rs index 4e81c1e1..246011eb 100644 --- a/crates/apl-delegator-oauth/src/lib.rs +++ b/builtins/plugins/delegator-oauth/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-delegator-oauth/src/lib.rs +// Location: ./builtins/plugins/delegator-oauth/src/lib.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-delegator-oauth — `TokenDelegateHandler` backed by RFC 8693 +// cpex-plugin-delegator-oauth — `TokenDelegateHandler` backed by RFC 8693 // OAuth 2.0 Token Exchange. // // The host registers this handler against `token.delegate`; outbound diff --git a/crates/apl-delegator-oauth/tests/oauth_e2e.rs b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs similarity index 99% rename from crates/apl-delegator-oauth/tests/oauth_e2e.rs rename to builtins/plugins/delegator-oauth/tests/oauth_e2e.rs index ff10351b..45e6a717 100644 --- a/crates/apl-delegator-oauth/tests/oauth_e2e.rs +++ b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-delegator-oauth/tests/oauth_e2e.rs +// Location: ./builtins/plugins/delegator-oauth/tests/oauth_e2e.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -28,7 +28,7 @@ use cpex_core::hooks::payload::Extensions; use cpex_core::manager::PluginManager; use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; -use apl_delegator_oauth::OAuthDelegator; +use cpex_plugin_delegator_oauth::OAuthDelegator; use mockito::{Matcher, Server}; use serde_json::json; diff --git a/crates/apl-identity-jwt/Cargo.toml b/builtins/plugins/identity-jwt/Cargo.toml similarity index 77% rename from crates/apl-identity-jwt/Cargo.toml rename to builtins/plugins/identity-jwt/Cargo.toml index 65dcf09b..11ba79e0 100644 --- a/crates/apl-identity-jwt/Cargo.toml +++ b/builtins/plugins/identity-jwt/Cargo.toml @@ -1,9 +1,9 @@ -# Location: ./crates/apl-identity-jwt/Cargo.toml +# Location: ./builtins/plugins/identity-jwt/Cargo.toml # Copyright 2025 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-identity-jwt — JWT-based `IdentityResolveHandler`. +# cpex-plugin-identity-jwt — JWT-based `IdentityResolveHandler`. # # Validates inbound JWTs against configured trusted issuers # (signature + exp + aud + iss claims) and maps the validated claims @@ -12,30 +12,19 @@ # `RawCredentialsExtension.inbound_tokens` for forwarding plugins # downstream. # -# # Why this exists alongside `apl-cedarling` -# -# Cedarling's JWT validation is bundled with Cedar policy -# evaluation — it doesn't expose validated identity as a separate -# data product. For deployments that want JWT validation without -# (or before) the Cedar policy step, this crate fills the gap. -# Lightweight (~5-15 transitive deps) vs Cedarling (~200). -# -# # Default-members -# -# This crate IS in the workspace's default-members — the dep tree -# is small enough that it doesn't slow down default builds. Compare -# to `apl-cedarling`, which is excluded. +# Lightweight (~5-15 transitive deps), so it stays in the workspace's +# default-members. [package] -name = "apl-identity-jwt" +name = "cpex-plugin-identity-jwt" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -apl-core = { path = "../apl-core" } -cpex-core = { path = "../cpex-core" } +apl-core = { path = "../../../crates/apl-core" } +cpex-core = { path = "../../../crates/cpex-core" } # `jsonwebtoken` is the de facto JWT library for Rust. ~5 transitive # deps (ring, base64, serde, pem). Supports RS256/RS384/RS512, @@ -63,7 +52,7 @@ 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. apl-delegator-oauth pulls reqwest +# 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"] } diff --git a/crates/apl-identity-jwt/src/claim_map.rs b/builtins/plugins/identity-jwt/src/claim_map.rs similarity index 99% rename from crates/apl-identity-jwt/src/claim_map.rs rename to builtins/plugins/identity-jwt/src/claim_map.rs index f1fbd5e2..ab6c1878 100644 --- a/crates/apl-identity-jwt/src/claim_map.rs +++ b/builtins/plugins/identity-jwt/src/claim_map.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/src/claim_map.rs +// Location: ./builtins/plugins/identity-jwt/src/claim_map.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs similarity index 99% rename from crates/apl-identity-jwt/src/config.rs rename to builtins/plugins/identity-jwt/src/config.rs index 37a70da7..dfd786a3 100644 --- a/crates/apl-identity-jwt/src/config.rs +++ b/builtins/plugins/identity-jwt/src/config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/src/config.rs +// Location: ./builtins/plugins/identity-jwt/src/config.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-identity-jwt/src/factory.rs b/builtins/plugins/identity-jwt/src/factory.rs similarity index 97% rename from crates/apl-identity-jwt/src/factory.rs rename to builtins/plugins/identity-jwt/src/factory.rs index 3306c4db..a036ebdb 100644 --- a/crates/apl-identity-jwt/src/factory.rs +++ b/builtins/plugins/identity-jwt/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/src/factory.rs +// Location: ./builtins/plugins/identity-jwt/src/factory.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs similarity index 71% rename from crates/apl-identity-jwt/src/lib.rs rename to builtins/plugins/identity-jwt/src/lib.rs index 2dff2158..d5447bb1 100644 --- a/crates/apl-identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -1,16 +1,15 @@ -// Location: ./crates/apl-identity-jwt/src/lib.rs +// Location: ./builtins/plugins/identity-jwt/src/lib.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-identity-jwt — JWT-based `IdentityResolveHandler` for APL. +// 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). Designed as the -// lightweight identity path that pairs with `apl-cedarling`'s -// PDP role — operators wanting both run identity here, policy -// gating through `cedarling:` steps. +// (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. // // Sub-step A scope: data shapes + module structure only. Actual // validation logic in sub-step B; multi-issuer + key rotation in @@ -32,14 +31,9 @@ // // # When to use this vs alternatives // -// - **`apl-identity-jwt`** (this crate) — JWT-only flow. +// - **`cpex-plugin-identity-jwt`** (this crate) — JWT-only flow. // Lightweight, ~5-15 transitive deps. The default choice for // "validate a Bearer token, extract identity." -// - **`apl-cedarling`** as identity (deferred) — Cedarling's API -// doesn't expose validated entities to callers, so we deferred -// wiring it as an IdentityResolveHandler. Use this crate for -// validation + a `cedarling:` step early in the route policy -// block if you want policy-driven identity gating. // - **Custom resolver** — anyone with bespoke identity flows // (mTLS-only, opaque tokens with introspection, capability // tokens) writes their own `HookHandler`. This diff --git a/crates/apl-identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs similarity index 97% rename from crates/apl-identity-jwt/src/resolver.rs rename to builtins/plugins/identity-jwt/src/resolver.rs index b7c517f1..699893c5 100644 --- a/crates/apl-identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/src/resolver.rs +// Location: ./builtins/plugins/identity-jwt/src/resolver.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -131,7 +131,7 @@ impl JwtIdentityResolver { let raw_config = cfg.config.as_ref().ok_or_else(|| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-identity-jwt) requires a `config:` block — \ + "plugin '{}' (cpex-plugin-identity-jwt) requires a `config:` block — \ missing trusted_issuers etc.", cfg.name ), @@ -142,7 +142,7 @@ impl JwtIdentityResolver { .map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-identity-jwt) config parse failed: {e}", + "plugin '{}' (cpex-plugin-identity-jwt) config parse failed: {e}", cfg.name ), }) @@ -151,7 +151,7 @@ impl JwtIdentityResolver { if typed.trusted_issuers.is_empty() { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-identity-jwt) requires at least one \ + "plugin '{}' (cpex-plugin-identity-jwt) requires at least one \ entry in `trusted_issuers`", cfg.name ), @@ -171,7 +171,7 @@ impl JwtIdentityResolver { // rather than at the async initialize() boundary. raw.validate().map_err(|e| { Box::new(PluginError::Config { - message: format!("plugin '{}' (apl-identity-jwt): {e}", cfg.name), + message: format!("plugin '{}' (cpex-plugin-identity-jwt): {e}", cfg.name), }) })?; if raw.decoding_key.needs_async() { @@ -179,7 +179,7 @@ impl JwtIdentityResolver { } else { let built = raw.build().map_err(|e| { Box::new(PluginError::Config { - message: format!("plugin '{}' (apl-identity-jwt): {e}", cfg.name), + message: format!("plugin '{}' (cpex-plugin-identity-jwt): {e}", cfg.name), }) })?; trusted_issuers.push(built); @@ -194,7 +194,7 @@ impl JwtIdentityResolver { Some(other) => { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-identity-jwt): unknown claim_mapper \ + "plugin '{}' (cpex-plugin-identity-jwt): unknown claim_mapper \ '{other}'; valid: [standard]", cfg.name ), @@ -211,7 +211,7 @@ impl JwtIdentityResolver { if matches!(typed.role, TokenRole::Custom(_)) { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-identity-jwt): role: Custom(...) is not \ + "plugin '{}' (cpex-plugin-identity-jwt): role: Custom(...) is not \ yet supported — pick one of `user`, `client`, `workload`", cfg.name ), @@ -220,7 +220,7 @@ impl JwtIdentityResolver { if typed.header.trim().is_empty() { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-identity-jwt): `header:` must be a \ + "plugin '{}' (cpex-plugin-identity-jwt): `header:` must be a \ non-empty HTTP header name", cfg.name ), diff --git a/crates/apl-identity-jwt/src/trusted_issuer.rs b/builtins/plugins/identity-jwt/src/trusted_issuer.rs similarity index 99% rename from crates/apl-identity-jwt/src/trusted_issuer.rs rename to builtins/plugins/identity-jwt/src/trusted_issuer.rs index a5acf6d4..935c8e83 100644 --- a/crates/apl-identity-jwt/src/trusted_issuer.rs +++ b/builtins/plugins/identity-jwt/src/trusted_issuer.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/src/trusted_issuer.rs +// Location: ./builtins/plugins/identity-jwt/src/trusted_issuer.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-identity-jwt/tests/jwks_url_e2e.rs b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs similarity index 99% rename from crates/apl-identity-jwt/tests/jwks_url_e2e.rs rename to builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs index b06e4518..4ce89051 100644 --- a/crates/apl-identity-jwt/tests/jwks_url_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/tests/jwks_url_e2e.rs +// Location: ./builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -28,7 +28,7 @@ use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENT use cpex_core::manager::PluginManager; use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; -use apl_identity_jwt::{DecodingKeySource, JwtIdentityResolver}; +use cpex_plugin_identity_jwt::{DecodingKeySource, JwtIdentityResolver}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use mockito::Server; diff --git a/crates/apl-identity-jwt/tests/jwt_e2e.rs b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs similarity index 98% rename from crates/apl-identity-jwt/tests/jwt_e2e.rs rename to builtins/plugins/identity-jwt/tests/jwt_e2e.rs index b18a6c3c..8ecba75d 100644 --- a/crates/apl-identity-jwt/tests/jwt_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-identity-jwt/tests/jwt_e2e.rs +// Location: ./builtins/plugins/identity-jwt/tests/jwt_e2e.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -28,7 +28,7 @@ use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENT use cpex_core::manager::PluginManager; use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; -use apl_identity_jwt::JwtIdentityResolver; +use cpex_plugin_identity_jwt::JwtIdentityResolver; use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; use rsa::{RsaPrivateKey, RsaPublicKey}; diff --git a/crates/apl-pii-scanner/Cargo.toml b/builtins/plugins/pii-scanner/Cargo.toml similarity index 76% rename from crates/apl-pii-scanner/Cargo.toml rename to builtins/plugins/pii-scanner/Cargo.toml index 89369aae..c5ae6ed9 100644 --- a/crates/apl-pii-scanner/Cargo.toml +++ b/builtins/plugins/pii-scanner/Cargo.toml @@ -1,21 +1,21 @@ -# Location: ./crates/apl-pii-scanner/Cargo.toml +# Location: ./builtins/plugins/pii-scanner/Cargo.toml # Copyright 2026 # SPDX-License-Identifier: Apache-2.0 # Authors: Teryl Taylor # -# apl-pii-scanner — CMF plugin that detects PII patterns (SSN, +# 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 = "apl-pii-scanner" +name = "cpex-plugin-pii-scanner" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -cpex-core = { path = "../cpex-core" } +cpex-core = { path = "../../../crates/cpex-core" } async-trait = { workspace = true } serde = { workspace = true } diff --git a/crates/apl-pii-scanner/src/config.rs b/builtins/plugins/pii-scanner/src/config.rs similarity index 98% rename from crates/apl-pii-scanner/src/config.rs rename to builtins/plugins/pii-scanner/src/config.rs index e0ecdeb7..abf24ac2 100644 --- a/crates/apl-pii-scanner/src/config.rs +++ b/builtins/plugins/pii-scanner/src/config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pii-scanner/src/config.rs +// Location: ./builtins/plugins/pii-scanner/src/config.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor diff --git a/crates/apl-pii-scanner/src/factory.rs b/builtins/plugins/pii-scanner/src/factory.rs similarity index 94% rename from crates/apl-pii-scanner/src/factory.rs rename to builtins/plugins/pii-scanner/src/factory.rs index 66f46995..ce7a5016 100644 --- a/crates/apl-pii-scanner/src/factory.rs +++ b/builtins/plugins/pii-scanner/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pii-scanner/src/factory.rs +// Location: ./builtins/plugins/pii-scanner/src/factory.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -37,7 +37,7 @@ impl PluginFactory for PiiScannerFactory { if config.hooks.is_empty() { return Err(Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-pii-scanner): `hooks:` must list at \ + "plugin '{}' (cpex-plugin-pii-scanner): `hooks:` must list at \ least one CMF hook to scan on (e.g. cmf.tool_pre_invoke)", config.name ), diff --git a/crates/apl-pii-scanner/src/lib.rs b/builtins/plugins/pii-scanner/src/lib.rs similarity index 87% rename from crates/apl-pii-scanner/src/lib.rs rename to builtins/plugins/pii-scanner/src/lib.rs index 6f5ee532..231167ae 100644 --- a/crates/apl-pii-scanner/src/lib.rs +++ b/builtins/plugins/pii-scanner/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-pii-scanner/src/lib.rs +// Location: ./builtins/plugins/pii-scanner/src/lib.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// apl-pii-scanner — CMF `HookHandler` that walks the message's +// 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: // diff --git a/crates/apl-pii-scanner/src/scanner.rs b/builtins/plugins/pii-scanner/src/scanner.rs similarity index 97% rename from crates/apl-pii-scanner/src/scanner.rs rename to builtins/plugins/pii-scanner/src/scanner.rs index 3b18c839..e87039d0 100644 --- a/crates/apl-pii-scanner/src/scanner.rs +++ b/builtins/plugins/pii-scanner/src/scanner.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-pii-scanner/src/scanner.rs +// Location: ./builtins/plugins/pii-scanner/src/scanner.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor @@ -35,7 +35,7 @@ impl PiiScanner { let raw = cfg.config.as_ref().ok_or_else(|| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-pii-scanner) requires a `config:` block", + "plugin '{}' (cpex-plugin-pii-scanner) requires a `config:` block", cfg.name ), }) @@ -44,7 +44,7 @@ impl PiiScanner { serde_json::from_value(raw.clone()).map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{}' (apl-pii-scanner) config parse failed: {e}", + "plugin '{}' (cpex-plugin-pii-scanner) config parse failed: {e}", cfg.name ), }) @@ -166,7 +166,7 @@ fn compile_patterns( let re = Regex::new(&re_str).map_err(|e| { Box::new(PluginError::Config { message: format!( - "plugin '{plugin_name}' (apl-pii-scanner): pattern '{name}' \ + "plugin '{plugin_name}' (cpex-plugin-pii-scanner): pattern '{name}' \ failed to compile: {e}" ), }) diff --git a/crates/apl-session-valkey/Cargo.toml b/builtins/session/valkey/Cargo.toml similarity index 87% rename from crates/apl-session-valkey/Cargo.toml rename to builtins/session/valkey/Cargo.toml index 270a7db1..b52a1983 100644 --- a/crates/apl-session-valkey/Cargo.toml +++ b/builtins/session/valkey/Cargo.toml @@ -1,9 +1,9 @@ -# Location: ./crates/apl-session-valkey/Cargo.toml +# Location: ./builtins/session/valkey/Cargo.toml # Copyright 2026 # SPDX-License-Identifier: Apache-2.0 # Authors: Fred Araujo # -# apl-session-valkey — a Valkey-backed `SessionStore` for distributed, +# cpex-session-valkey — a Valkey-backed `SessionStore` for distributed, # cross-restart persistence of session security labels. # # # Dependency discipline @@ -11,20 +11,20 @@ # 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` (mirrors apl-cedarling). The redis client is pinned with +# `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 -# apl-identity-jwt / apl-delegator-oauth. +# cpex-plugin-identity-jwt / cpex-plugin-delegator-oauth. [package] -name = "apl-session-valkey" +name = "cpex-session-valkey" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -apl-cpex = { path = "../apl-cpex" } +apl-cpex = { path = "../../../crates/apl-cpex" } async-trait = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/apl-session-valkey/src/config.rs b/builtins/session/valkey/src/config.rs similarity index 99% rename from crates/apl-session-valkey/src/config.rs rename to builtins/session/valkey/src/config.rs index c103c474..8b335129 100644 --- a/crates/apl-session-valkey/src/config.rs +++ b/builtins/session/valkey/src/config.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-session-valkey/src/config.rs +// Location: ./builtins/session/valkey/src/config.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo diff --git a/crates/apl-session-valkey/src/connection.rs b/builtins/session/valkey/src/connection.rs similarity index 95% rename from crates/apl-session-valkey/src/connection.rs rename to builtins/session/valkey/src/connection.rs index 526a4844..28c0d0c7 100644 --- a/crates/apl-session-valkey/src/connection.rs +++ b/builtins/session/valkey/src/connection.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-session-valkey/src/connection.rs +// Location: ./builtins/session/valkey/src/connection.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo diff --git a/crates/apl-session-valkey/src/error.rs b/builtins/session/valkey/src/error.rs similarity index 95% rename from crates/apl-session-valkey/src/error.rs rename to builtins/session/valkey/src/error.rs index 38169e9d..fd6b791d 100644 --- a/crates/apl-session-valkey/src/error.rs +++ b/builtins/session/valkey/src/error.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-session-valkey/src/error.rs +// Location: ./builtins/session/valkey/src/error.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo diff --git a/crates/apl-session-valkey/src/factory.rs b/builtins/session/valkey/src/factory.rs similarity index 95% rename from crates/apl-session-valkey/src/factory.rs rename to builtins/session/valkey/src/factory.rs index 576918cd..00a94f6d 100644 --- a/crates/apl-session-valkey/src/factory.rs +++ b/builtins/session/valkey/src/factory.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-session-valkey/src/factory.rs +// Location: ./builtins/session/valkey/src/factory.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo diff --git a/crates/apl-session-valkey/src/lib.rs b/builtins/session/valkey/src/lib.rs similarity index 87% rename from crates/apl-session-valkey/src/lib.rs rename to builtins/session/valkey/src/lib.rs index 5449f736..21188966 100644 --- a/crates/apl-session-valkey/src/lib.rs +++ b/builtins/session/valkey/src/lib.rs @@ -1,9 +1,9 @@ -// Location: ./crates/apl-session-valkey/src/lib.rs +// Location: ./builtins/session/valkey/src/lib.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// apl-session-valkey — a Valkey-backed `apl_cpex::SessionStore` for +// cpex-session-valkey — a Valkey-backed `apl_cpex::SessionStore` for // distributed, cross-restart persistence of session security labels. // // # Where this sits @@ -11,7 +11,7 @@ // apl-cpex (SessionStore trait, SessionStoreFactory) // ▲ // │ implements -// apl-session-valkey ──uses──▶ redis-rs + deadpool-redis (rustls) +// cpex-session-valkey ──uses──▶ redis-rs + deadpool-redis (rustls) // // The host registers `ValkeySessionStoreFactory` via // `AplOptions.session_store_factories`; a `global.apl.session_store: diff --git a/crates/apl-session-valkey/src/store.rs b/builtins/session/valkey/src/store.rs similarity index 99% rename from crates/apl-session-valkey/src/store.rs rename to builtins/session/valkey/src/store.rs index 9f80d4e0..a16c15af 100644 --- a/crates/apl-session-valkey/src/store.rs +++ b/builtins/session/valkey/src/store.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-session-valkey/src/store.rs +// Location: ./builtins/session/valkey/src/store.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo diff --git a/crates/apl-session-valkey/tests/valkey_store_integration.rs b/builtins/session/valkey/tests/valkey_store_integration.rs similarity index 97% rename from crates/apl-session-valkey/tests/valkey_store_integration.rs rename to builtins/session/valkey/tests/valkey_store_integration.rs index f3e22862..28aeaaa3 100644 --- a/crates/apl-session-valkey/tests/valkey_store_integration.rs +++ b/builtins/session/valkey/tests/valkey_store_integration.rs @@ -1,4 +1,4 @@ -// Location: ./crates/apl-session-valkey/tests/valkey_store_integration.rs +// Location: ./builtins/session/valkey/tests/valkey_store_integration.rs // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo @@ -7,7 +7,7 @@ // container (testcontainers). These are `#[ignore]`d by default so unit // runs don't require Docker; run them with: // -// cargo test -p apl-session-valkey -- --ignored +// 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 @@ -20,7 +20,7 @@ // stops a silent green. use apl_cpex::{SessionStore, SessionStoreError}; -use apl_session_valkey::{ValkeyConfig, ValkeySessionStore}; +use cpex_session_valkey::{ValkeyConfig, ValkeySessionStore}; use sha2::{Digest, Sha256}; use testcontainers_modules::testcontainers::runners::AsyncRunner; use testcontainers_modules::testcontainers::ContainerAsync; diff --git a/crates/apl-cedarling/Cargo.toml b/crates/apl-cedarling/Cargo.toml deleted file mode 100644 index b0c0dc8a..00000000 --- a/crates/apl-cedarling/Cargo.toml +++ /dev/null @@ -1,64 +0,0 @@ -# Location: ./crates/apl-cedarling/Cargo.toml -# Copyright 2025 -# SPDX-License-Identifier: Apache-2.0 -# Authors: Teryl Taylor -# -# apl-cedarling — Cedarling-backed IdentityResolveHandler and -# PdpResolver implementations. -# -# Two modules in one crate because they share the same heavy dep -# (cedarling) and almost always run in the same deployment — an -# operator using Cedarling for identity resolution invariably also -# wants it for policy decisions, and the two consume the same -# `Cedarling` instance + policy store. -# -# # Why this crate isn't in default-members -# -# Cedarling pulls ~200 transitive dependencies (jsonwebtoken, reqwest, -# sparkv, datalogic-rs, flate2, regex, ahash, time, vfs, zip, …). To -# keep `cargo build` at the workspace root fast for the majority of -# iteration, the workspace excludes this crate from `default-members`. -# Build it explicitly with `cargo build -p apl-cedarling` or with -# `cargo build --workspace` for the full sweep. - -[package] -name = "apl-cedarling" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true - -[dependencies] -apl-core = { path = "../apl-core" } -cpex-core = { path = "../cpex-core" } - -# Cedarling lives in the Janssen Project monorepo at the path -# `jans-cedarling/cedarling/` within that repo. We pin to a release -# tag rather than a branch so the dep tree stays reproducible across -# checkouts — bump the tag deliberately when we want a new version. -# -# `package = "cedarling"` tells Cargo which named crate to pick from -# the monorepo's multiple workspaces. `default-features = false` -# disables `grpc` (tonic+prost for Lock Server); Lock Server -# integration lands behind its own feature flag if/when we wire it. -# -# First build for new collaborators clones the Janssen monorepo (~200 -# transitive deps + cedarling's vendored workspace). Cached in -# ~/.cargo/git/ afterward. -cedarling = { git = "https://github.com/JanssenProject/jans", tag = "v2.1.0", package = "cedarling", default-features = false } - -# cedar-policy is a direct dep so we can name `cedar_policy::Decision` -# etc. in the resolver. Caret spec lets Cargo dedup to the same -# version Cedarling pulls (currently 4.11.0 transitively). -cedar-policy = "4" - -async-trait = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-cedarling/src/error.rs b/crates/apl-cedarling/src/error.rs deleted file mode 100644 index d81fab69..00000000 --- a/crates/apl-cedarling/src/error.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Location: ./crates/apl-cedarling/src/error.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Build-time errors for constructing Cedarling-backed resolvers and -// handlers. Runtime errors flow through `PluginViolation` (for -// hook handlers) or `PdpError::Dispatch` (for the PDP path) — same -// pattern as `apl-pdp-cedar-direct`. - -use thiserror::Error; - -/// Errors that can occur while constructing a Cedarling-backed -/// resolver or handler from config. -#[non_exhaustive] -#[derive(Debug, Error)] -pub enum CedarlingPluginError { - /// The policy store file/URL couldn't be loaded. - #[error("failed to load policy store: {0}")] - PolicyStoreLoad(String), - - /// The bootstrap config was malformed or missing required fields. - #[error("invalid Cedarling bootstrap config: {0}")] - BootstrapConfig(String), - - /// Cedarling itself failed to initialize (JWKS unreachable, - /// schema validation failed, etc.). - #[error("Cedarling initialization failed: {0}")] - Init(String), -} diff --git a/crates/apl-cedarling/src/identity/mod.rs b/crates/apl-cedarling/src/identity/mod.rs deleted file mode 100644 index 1d7fdd8c..00000000 --- a/crates/apl-cedarling/src/identity/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -// Location: ./crates/apl-cedarling/src/identity/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Cedarling-backed IdentityResolveHandler. -// -// Sub-step A scope: stub module. Actual implementation lands in -// sub-step B. -// -// # Planned shape -// -// ```ignore -// pub struct CedarlingIdentityResolver { -// cedarling: Arc, -// // optional: which sentinel action to use for identity-only -// // validation when no real policy decision is being made -// identity_action: String, -// } -// -// impl HookHandler for CedarlingIdentityResolver { -// async fn handle(&self, payload: &IdentityPayload, ...) -> ... { -// // Build TokenInputs from payload.raw_token() + headers -// // Call cedarling.authorize_multi_issuer with sentinel action -// // If decision is deny -> PluginResult::deny(violation) -// // If allow -> extract validated entities, map to -// // SubjectExtension / ClientExtension / WorkloadIdentity -// // and return modified payload -// } -// } -// ``` diff --git a/crates/apl-cedarling/src/lib.rs b/crates/apl-cedarling/src/lib.rs deleted file mode 100644 index fb2ae21b..00000000 --- a/crates/apl-cedarling/src/lib.rs +++ /dev/null @@ -1,48 +0,0 @@ -// Location: ./crates/apl-cedarling/src/lib.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// apl-cedarling — Cedarling-backed plugins for APL's two adjacent -// auth seams: -// -// * [`identity`] — `IdentityResolveHandler` that validates inbound -// JWTs through Cedarling and maps validated tokens into -// `SubjectExtension` / `ClientExtension`. Optionally runs an -// advisory Cedar policy check ("is this principal allowed at all") -// during the validation pass. -// * [`pdp`] — `PdpResolver` for `cedar:(...)` steps in APL routes. -// Mirrors the cedar-direct resolver but uses Cedarling's policy -// store loading + (eventually) Lock Server hooks instead of -// in-process `cedar-policy::PolicySet`. -// -// Both modules share a single `Cedarling` instance constructed from -// the same bootstrap config — operators using one almost always want -// the other, and double-loading the policy store / JWKS would be -// wasteful. -// -// # When to reach for this crate vs alternatives -// -// - **`apl-pdp-cedar-direct`** — simpler, ~5 transitive deps, -// policies as inline text. Use for tests, dev, or deployments -// that don't need policy-store signing / centralized management. -// - **`apl-identity-jwt`** (future) — JWT validation via the -// `jsonwebtoken` crate, no Cedar coupling, ~5 transitive deps. -// Use when you want lightweight identity without policy-driven -// identity decisions. -// - **`apl-cedarling`** (this crate) — heavy dep tree but gives you -// signed policy stores, Cedar-driven identity decisions, and -// (future) Lock Server fleet management. Use for production -// deployments with centralized policy management. -// -// # Sub-step A scope -// -// Module skeletons + crate wiring only. No actual Cedarling calls. -// Existence of this crate validates the dep-resolution cost honestly -// before we commit to the implementation. - -pub mod error; -pub mod identity; -pub mod pdp; - -pub use error::CedarlingPluginError; diff --git a/crates/apl-cedarling/src/pdp/mod.rs b/crates/apl-cedarling/src/pdp/mod.rs deleted file mode 100644 index dad4ac3d..00000000 --- a/crates/apl-cedarling/src/pdp/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -// Location: ./crates/apl-cedarling/src/pdp/mod.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Cedarling-backed PdpResolver. - -pub mod resolver; - -pub use resolver::CedarlingPdpResolver; diff --git a/crates/apl-cedarling/src/pdp/resolver.rs b/crates/apl-cedarling/src/pdp/resolver.rs deleted file mode 100644 index 076f2854..00000000 --- a/crates/apl-cedarling/src/pdp/resolver.rs +++ /dev/null @@ -1,374 +0,0 @@ -// Location: ./crates/apl-cedarling/src/pdp/resolver.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// `CedarlingPdpResolver` — `PdpResolver` impl that delegates Cedar -// policy evaluation to a Cedarling instance. -// -// # Why Cedarling here instead of `apl-pdp-cedar-direct` -// -// Both call the same Cedar evaluator under the hood. The difference -// is the policy-store loading + management layer Cedarling provides: -// signed policy bundles, multi-policy stores keyed by ID, optional -// Lock Server integration for fleet-wide updates. Deployments that -// don't need any of that should reach for `apl-pdp-cedar-direct` -// instead — it's ~5 deps vs ~200. -// -// # Construction -// -// This resolver does NOT construct its own Cedarling instance. -// Cedarling holds shared state (JWT keys, entity store cache, -// optional Lock Server connection) that an entire deployment -// typically wants to share between identity resolution and PDP -// evaluation. The host builds one `Arc` at startup and -// hands the same handle to both this resolver and the -// (forthcoming) `CedarlingIdentityResolver`. -// -// # `authorize_unsigned` -// -// We use Cedarling's `authorize_unsigned` rather than -// `authorize_multi_issuer`. Reasoning: -// * APL has already done identity resolution by the time `cedar:` -// policy steps run — `Extensions.security.subject` / -// `.client` / `.caller_workload` are populated. -// * We build the principal entity from the `AttributeBag` directly, -// bypassing Cedarling's JWT-validation path entirely. -// * No sentinel-action workaround needed (the one we discussed for -// using `authorize_multi_issuer` purely for identity). - -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; -use cedarling::{CedarEntityMapping, Cedarling, EntityData, RequestUnsigned}; -use serde_json::{json, Map, Value}; - -use apl_core::attributes::{AttributeBag, AttributeValue}; -use apl_core::evaluator::Decision; -use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; - -/// `PdpResolver` that dispatches policy decisions to a Cedarling -/// instance. See module docs for when to prefer this over -/// `apl-pdp-cedar-direct`. -pub struct CedarlingPdpResolver { - /// Shared Cedarling instance — built once at host startup, - /// passed to both this resolver and the identity handler. - cedarling: Arc, - - /// The dialect this resolver registers under in the `PdpRouter`. - /// Defaults to `PdpDialect::Cedarling` (a distinct variant from - /// `Cedar`) so both `apl-pdp-cedar-direct` and this crate can - /// coexist in the same router and routes target each explicitly - /// via `cedar:(...)` vs `cedarling:(...)` step keys. - dialect: PdpDialect, - - /// Optional namespace prefix prepended to entity types built - /// from the bag (`"User"` → `"Jans::User"`). Matches the - /// `apl-pdp-cedar-direct` ergonomics; deployments with - /// namespaced schemas set this once at startup. - entity_namespace: Option, -} - -impl CedarlingPdpResolver { - /// Build a resolver around a pre-constructed Cedarling instance. - /// Cedarling construction is async and config-heavy - /// (`BootstrapConfig`, policy store loading); doing it inside - /// the resolver would force every call site into an async - /// context. The host owns the lifecycle. - pub fn new(cedarling: Arc) -> Self { - Self { - cedarling, - dialect: PdpDialect::Cedarling, - entity_namespace: None, - } - } - - pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { - self.dialect = dialect; - self - } - - pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { - self.entity_namespace = Some(namespace.into()); - self - } -} - -#[async_trait] -impl PdpResolver for CedarlingPdpResolver { - fn dialect(&self) -> PdpDialect { - self.dialect.clone() - } - - async fn evaluate( - &self, - call: &PdpCall, - bag: &AttributeBag, - ) -> Result { - let map = call.args.as_mapping().ok_or_else(|| { - PdpError::Dispatch( - "cedarling: cedar:() args must be a mapping with action/resource keys" - .to_string(), - ) - })?; - - let action = yaml_string(map, "action").ok_or_else(|| { - PdpError::Dispatch("cedarling: cedar:() args.action missing or not a string".into()) - })?; - - let resource_value = map - .get(serde_yaml::Value::String("resource".to_string())) - .ok_or_else(|| { - PdpError::Dispatch("cedarling: cedar:() args.resource missing".into()) - })?; - let resource = build_resource_entity_data(resource_value)?; - - let principal = - build_principal_entity_data(bag, self.entity_namespace.as_deref())?; - - let context = map - .get(serde_yaml::Value::String("context".to_string())) - .map(|v| serde_json::to_value(v)) - .transpose() - .map_err(|e| { - PdpError::Dispatch(format!( - "cedarling: cedar:() args.context not JSON-representable: {e}" - )) - })? - .unwrap_or(Value::Object(Map::new())); - - let request = RequestUnsigned { - principal: Some(principal), - action, - resource, - context, - }; - - let result = self.cedarling.authorize_unsigned(request).await.map_err(|e| { - PdpError::Dispatch(format!("cedarling: authorize_unsigned failed: {e}")) - })?; - - Ok(translate_authorize_result(&result)) - } -} - -// ===================================================================== -// Helpers -// ===================================================================== - -/// Build the Cedarling principal entity from the attribute bag. Same -/// claim shape as `apl-pdp-cedar-direct`: -/// -/// * `subject.id` → entity id (required) -/// * `subject.type` → entity type ("User" default) -/// * `role.=true` → attrs.roles : Set -/// * `perm.=true` → attrs.permissions : Set -/// * `claim.=v` → attrs.claims. = v -/// * `subject.teams` → attrs.teams : Set -/// -/// Returns `EntityData` (Cedarling's JSON-shaped entity carrier), -/// which Cedarling converts internally to a `cedar_policy::Entity`. -fn build_principal_entity_data( - bag: &AttributeBag, - namespace: Option<&str>, -) -> Result { - let id = bag - .get_string("subject.id") - .ok_or_else(|| { - PdpError::Dispatch( - "cedarling: 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, namespace); - - let mut attributes: HashMap = HashMap::new(); - attributes.insert("id".to_string(), json!(id)); - attributes.insert("type".to_string(), json!(kind)); - - let roles = collect_prefixed_bools(bag, "role."); - attributes.insert("roles".to_string(), json!(roles)); - - let permissions = collect_prefixed_bools(bag, "perm."); - attributes.insert("permissions".to_string(), json!(permissions)); - - let teams: Vec = bag - .get_string_set("subject.teams") - .map(|s| s.iter().cloned().collect()) - .unwrap_or_default(); - attributes.insert("teams".to_string(), json!(teams)); - - let claims = collect_claims(bag); - attributes.insert("claims".to_string(), Value::Object(claims)); - - Ok(EntityData { - cedar_mapping: CedarEntityMapping { - entity_type, - id, - }, - attributes, - }) -} - -/// Build the resource entity from the policy author's `args.resource` -/// block: -/// -/// ```yaml -/// resource: -/// type: Document # required -/// id: doc-42 # required -/// attributes: # optional -/// classification: internal -/// ``` -fn build_resource_entity_data( - resource_args: &serde_yaml::Value, -) -> Result { - let map = resource_args.as_mapping().ok_or_else(|| { - PdpError::Dispatch( - "cedarling: cedar:() args.resource must be a mapping".to_string(), - ) - })?; - let entity_type = yaml_string(map, "type").ok_or_else(|| { - PdpError::Dispatch("cedarling: cedar:() args.resource.type missing".to_string()) - })?; - let id = yaml_string(map, "id").ok_or_else(|| { - PdpError::Dispatch("cedarling: cedar:() args.resource.id missing".to_string()) - })?; - - let mut attributes: HashMap = HashMap::new(); - if let Some(attrs_value) = map.get(serde_yaml::Value::String("attributes".to_string())) - { - let attrs_json: Value = serde_json::to_value(attrs_value).map_err(|e| { - PdpError::Dispatch(format!( - "cedarling: cedar:() args.resource.attributes not JSON-representable: {e}" - )) - })?; - if let Value::Object(map) = attrs_json { - for (k, v) in map { - attributes.insert(k, v); - } - } - } - - Ok(EntityData { - cedar_mapping: CedarEntityMapping { - entity_type, - id, - }, - attributes, - }) -} - -/// Translate Cedarling's `AuthorizeResult` into APL's `PdpDecision`. -/// Mirrors `apl-pdp-cedar-direct`'s decision-translation logic since -/// both crates ultimately read the same `cedar_policy::Response`. -/// Fail-closed on diagnostic errors. -fn translate_authorize_result(result: &cedarling::AuthorizeResult) -> PdpDecision { - use cedar_policy::Decision as CedarDecision; - let response = &result.response; - let diagnostics = response.diagnostics(); - - let firing_policies: Vec = diagnostics - .reason() - .map(|pid| pid.to_string()) - .collect(); - - let errors: Vec = diagnostics.errors().map(|e| e.to_string()).collect(); - - // Cedar evaluation errors → fail-closed deny. Same rule as - // `apl-pdp-cedar-direct`: any runtime error during evaluation - // produces an untrustworthy decision, so we override to deny. - 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 => { - let reason = if firing_policies.is_empty() { - "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, - } -} - -// ----- Small helpers, mirror cedar-direct ----- - -fn qualify_type(bare: &str, namespace: Option<&str>) -> String { - match namespace { - Some(ns) if !ns.is_empty() => format!("{ns}::{bare}"), - _ => bare.to_string(), - } -} - -fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { - use std::collections::HashSet; - 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 -} - -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/crates/apl-cedarling/tests/pdp_basic.rs b/crates/apl-cedarling/tests/pdp_basic.rs deleted file mode 100644 index 0fee5f07..00000000 --- a/crates/apl-cedarling/tests/pdp_basic.rs +++ /dev/null @@ -1,166 +0,0 @@ -// Location: ./crates/apl-cedarling/tests/pdp_basic.rs -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Basic e2e for `CedarlingPdpResolver`: build a Cedarling instance -// against an inline policy store, dispatch a `cedar:` call through -// the resolver, assert the allow/deny path. -// -// This test exercises the full Cedarling stack — bootstrap config -// parsing, policy store loading, schema validation, Cedar evaluation, -// response translation. The `policy-store_no_trusted_issuers.yaml` -// pattern (no trusted JWT issuers configured) is what makes -// `authorize_unsigned` viable for us — Cedarling skips its JWT -// validation path entirely when there are no trusted issuers, so we -// can drive policy decisions purely from the bag-built entities. - -use std::sync::Arc; - -use apl_core::attributes::AttributeBag; -use apl_core::evaluator::Decision; -use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; - -use apl_cedarling::pdp::CedarlingPdpResolver; -use cedarling::{BootstrapConfig, Cedarling, PolicyStoreSource}; - -/// Minimal policy store: one permit policy that fires for -/// `Action::"read"` against any `Document` when the principal -/// carries `roles` containing "reader". The schema declares a -/// `Jans` namespace so policy IDs / entities resolve cleanly. -const POLICY_STORE_YAML: &str = r#" -cedar_version: v4.0.0 -policy_stores: - test-store-001: - cedar_version: v4.0.0 - name: "test" - policies: - 1: - description: reader-only read permit - creation_date: "2026-05-21T00:00:00.000000" - policy_content: - encoding: none - content_type: cedar - body: |- - permit( - principal, - action == Jans::Action::"read", - resource - )when{ - principal.roles.contains("reader") - }; - schema: - encoding: none - content_type: cedar - body: |- - namespace Jans { - entity Document = { "classification": String }; - entity User = { "roles": Set }; - action "read" appliesTo { - principal: [User], - resource: [Document], - context: {} - }; - } -"#; - -/// Build a Cedarling instance configured with the test policy store -/// and no trusted JWT issuers — so `authorize_unsigned` is the right -/// path (no token validation involved). -async fn build_cedarling() -> Arc { - let mut config = BootstrapConfig::default(); - config.application_name = "apl-cedarling-test".to_string(); - config.policy_store_config.source = - PolicyStoreSource::Yaml(POLICY_STORE_YAML.to_string()); - let cedarling = Cedarling::new(&config) - .await - .expect("Cedarling::new should succeed with valid config"); - Arc::new(cedarling) -} - -fn alice_with_reader_role() -> AttributeBag { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "alice"); - bag.set("subject.type", "User"); - bag.set("role.reader", true); - bag -} - -fn bob_no_roles() -> AttributeBag { - let mut bag = AttributeBag::new(); - bag.set("subject.id", "bob"); - bag.set("subject.type", "User"); - bag -} - -fn read_doc_call() -> PdpCall { - PdpCall { - // Route YAML `cedarling:(...)` produces this dialect. - // `apl-pdp-cedar-direct` registers under `PdpDialect::Cedar` - // so both resolvers can coexist in one PdpRouter. - dialect: PdpDialect::Cedarling, - args: serde_yaml::from_str( - r#" -action: 'Jans::Action::"read"' -resource: - type: Jans::Document - id: doc-42 - attributes: - classification: internal -"#, - ) - .unwrap(), - } -} - -#[tokio::test] -async fn reader_role_allows() { - let cedarling = build_cedarling().await; - let resolver = CedarlingPdpResolver::new(cedarling) - .with_entity_namespace("Jans"); - let decision = resolver - .evaluate(&read_doc_call(), &alice_with_reader_role()) - .await - .expect("evaluate should succeed"); - assert!( - matches!(decision.decision, Decision::Allow), - "alice with role.reader should be allowed: got {:?}", - decision.decision, - ); -} - -#[tokio::test] -async fn missing_role_default_denies() { - let cedarling = build_cedarling().await; - let resolver = CedarlingPdpResolver::new(cedarling) - .with_entity_namespace("Jans"); - let decision = resolver - .evaluate(&read_doc_call(), &bob_no_roles()) - .await - .expect("evaluate should succeed"); - match decision.decision { - Decision::Deny { rule_source, .. } => { - // No permit fired → cedar.default_deny sentinel. - assert_eq!(rule_source, "cedar.default_deny"); - } - Decision::Allow => panic!("bob without reader role should be denied"), - } -} - -#[tokio::test] -async fn missing_subject_id_errors_clearly() { - let cedarling = build_cedarling().await; - let resolver = CedarlingPdpResolver::new(cedarling); - // Bag with no subject.id at all — resolver should fail - // construction of the principal entity with a clear error. - let bag = AttributeBag::new(); - let err = resolver - .evaluate(&read_doc_call(), &bag) - .await - .expect_err("missing subject.id should error"); - let msg = format!("{err:?}"); - assert!( - msg.contains("subject.id"), - "error should call out the missing key, got: {msg}", - ); -} diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index f99848e4..04e4752b 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -579,10 +579,10 @@ fn parse_require_rule(line: &str) -> Result { }) } -/// Detect `taint(...)` / `plugin(...)` / `run(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(` / `cel:`. +/// 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:", "cedarling:", "opa(", "authzen(", "nemo(", "cel:", "sequential:", "parallel:"] { + 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(':')); } @@ -1188,7 +1188,7 @@ fn is_known_pdp_dialect(key: &str) -> bool { let base = key.find('(').map(|i| &key[..i]).unwrap_or(key); matches!( base.trim(), - "cedar" | "cedarling" | "opa" | "authzen" | "nemo" | "cel" + "cedar" | "opa" | "authzen" | "nemo" | "cel" ) } @@ -3289,36 +3289,6 @@ routes: } } - #[test] - fn compile_pdp_call_cedarling_map_form() { - // `cedarling:` is its own dialect — same map shape as `cedar:` - // but routes to the Cedarling-backed resolver in the - // PdpRouter, letting cedar-direct and cedarling coexist. - let yaml = r#" -routes: - authz_check: - policy: - - cedarling: - action: read - resource: employee - 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, .. } => { - assert_eq!(call.dialect, PdpDialect::Cedarling); - let args_map = call.args.as_mapping().expect("cedarling 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); - } - 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. diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index 15e49f54..a3f05e9c 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -151,19 +151,12 @@ pub struct PdpCall { #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum PdpDialect { - /// Bare Cedar policy evaluation (`apl-pdp-cedar-direct`). + /// Bare Cedar policy evaluation (`cpex-pdp-cedar-direct`). Cedar, - /// Cedarling-mediated Cedar evaluation — same language but - /// adds signed policy stores, multi-issuer JWT validation, and - /// (with Lock Server) centralized policy management. Distinct - /// from `Cedar` so both can coexist in a single `PdpRouter`; - /// route YAML can target either with `cedar:(...)` or - /// `cedarling:(...)` keys. - Cedarling, Opa, AuthZen, NeMo, - /// CEL (Common Expression Language) evaluation — `apl-pdp-cel`. + /// 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`, …). @@ -177,13 +170,11 @@ pub enum PdpDialect { } impl PdpDialect { - /// Parse a YAML key prefix like `cedar`, `cedarling`, `opa`, - /// `authzen`, `nemo` into the matching `PdpDialect`. Unknown - /// dialects become `Custom`. + /// 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, - "cedarling" => Self::Cedarling, "opa" => Self::Opa, "authzen" => Self::AuthZen, "nemo" => Self::NeMo, @@ -197,9 +188,9 @@ impl PdpDialect { // Resolver traits // ===================================================================== -/// External policy-decision dispatch. Implemented by Cedar/Cedarling, OPA -/// HTTP clients, AuthZen clients, NeMo Guardrails — anything that can -/// answer "given this call, allow or deny?" against a request context. +/// 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. @@ -217,7 +208,7 @@ pub trait PdpResolver: Send + Sync { } /// Build a [`PdpResolver`] from a unified-config block. Implemented per -/// PDP backend (cedar-direct, cedarling, opa, …) and registered with +/// 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. /// @@ -233,7 +224,7 @@ pub trait PdpResolver: Send + Sync { 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"`, `"cedarling"`, `"opa"`, …). + /// (`"cedar-direct"`, `"opa"`, …). fn kind(&self) -> &str; /// Build a resolver from the rest of the PDP config block (everything @@ -522,7 +513,6 @@ mod tests { #[test] fn from_key_maps_known_dialects() { assert_eq!(PdpDialect::from_key("cedar"), PdpDialect::Cedar); - assert_eq!(PdpDialect::from_key("cedarling"), PdpDialect::Cedarling); assert_eq!(PdpDialect::from_key("opa"), PdpDialect::Opa); assert_eq!(PdpDialect::from_key("authzen"), PdpDialect::AuthZen); assert_eq!(PdpDialect::from_key("nemo"), PdpDialect::NeMo); diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs index 23cb2de0..7dab81e7 100644 --- a/crates/apl-cpex/src/pdp_router.rs +++ b/crates/apl-cpex/src/pdp_router.rs @@ -11,12 +11,12 @@ // The PDP backends that ship in this workspace, each its own crate // registered here by dialect: // -// - **cedar** (`apl-pdp-cedar-direct`) / **cedarling** (`apl-cedarling`) -// — Cedar policy-set evaluation, in-process and via Cedarling. +// - **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** (`apl-pdp-cel`) — inline CEL boolean predicates authored in +// - **cel** (`cpex-pdp-cel`) — inline CEL boolean predicates authored in // the route YAML (`cel: { expr: "..." }`); smallest dep tree, no // external policy store. // diff --git a/crates/apl-cpex/src/register.rs b/crates/apl-cpex/src/register.rs index 13e03083..63b39ff0 100644 --- a/crates/apl-cpex/src/register.rs +++ b/crates/apl-cpex/src/register.rs @@ -69,7 +69,7 @@ pub struct AplOptions { /// 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`, `cedarling`, `opa`. An empty list disables + /// `cedar-direct`, `opa`. An empty list disables /// config-driven PDP wiring; hosts can still supply resolvers via /// `pdps`. pub pdp_factories: Vec>, @@ -134,7 +134,7 @@ impl AplOptions { /// use std::sync::Arc; /// use cpex_core::manager::PluginManager; /// use apl_cpex::{register_apl, AplOptions}; -/// use apl_pdp_cedar_direct::CedarDirectPdpFactory; +/// use cpex_pdp_cedar_direct::CedarDirectPdpFactory; /// /// let mgr = Arc::new(PluginManager::default()); /// mgr.register_factory("scope-gate", Box::new(ScopeGateFactory)); diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs index 1d4ed95b..ec444ac9 100644 --- a/crates/apl-cpex/src/session_resolver.rs +++ b/crates/apl-cpex/src/session_resolver.rs @@ -38,7 +38,7 @@ // // 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16]. // No special infrastructure needed; the triple is already populated -// by `apl-identity-jwt`'s claim mapping. Same user + same agent + +// 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). // @@ -49,7 +49,7 @@ // // Each tier reads from a typed `Extensions` field, not raw JWT/HTTP // payloads — those have already been mapped by upstream identity -// plugins (apl-identity-jwt). The resolver stays free of crypto / +// plugins (cpex-plugin-identity-jwt). The resolver stays free of crypto / // parsing logic. use cpex_core::extensions::Extensions; @@ -121,7 +121,7 @@ fn subject_scoped(subject_id: Option<&str>, raw: &str) -> Option { /// a single gateway and single agent. pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> { // The authenticated subject, populated by the identity resolvers - // (apl-identity-jwt) before this runs. Every client/upstream-supplied + // (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 diff --git a/crates/apl-cpex/src/session_store.rs b/crates/apl-cpex/src/session_store.rs index b227a8c3..c70e22a9 100644 --- a/crates/apl-cpex/src/session_store.rs +++ b/crates/apl-cpex/src/session_store.rs @@ -39,7 +39,7 @@ use async_trait::async_trait; /// /// 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 `apl-session-valkey` backend can +/// 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** diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs index 6102ebcd..1c7b123a 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -125,7 +125,7 @@ struct VisitorState { /// `kind`, and constructs the resolver during `visit_global`. /// /// Factories are registered up front by `kind` name (`"cedar-direct"`, -/// `"cedarling"`, …). The visitor knows nothing about specific PDP +/// `"opa"`, …). The visitor knows nothing about specific PDP /// backends; everything dispatches through `PdpFactory`. pub struct AplConfigVisitor { state: RwLock, diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml new file mode 100644 index 00000000..0aded972 --- /dev/null +++ b/crates/cpex-builtins/Cargo.toml @@ -0,0 +1,58 @@ +# 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 + +[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", "cedar-direct", "cel"] + +# 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"] +cedar-direct = ["dep:cpex-pdp-cedar-direct"] +cel = ["dep:cpex-pdp-cel"] +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 = { path = "../cpex-core" } +apl-core = { path = "../apl-core" } +apl-cpex = { path = "../apl-cpex" } + +# Builtin extension crates — each behind its feature. +cpex-plugin-pii-scanner = { path = "../../builtins/plugins/pii-scanner", optional = true } +cpex-plugin-audit-logger = { path = "../../builtins/plugins/audit-logger", optional = true } +cpex-plugin-identity-jwt = { path = "../../builtins/plugins/identity-jwt", optional = true } +cpex-plugin-delegator-oauth = { path = "../../builtins/plugins/delegator-oauth", optional = true } +cpex-pdp-cedar-direct = { path = "../../builtins/pdps/cedar-direct", optional = true } +cpex-pdp-cel = { path = "../../builtins/pdps/cel", optional = true } +cpex-session-valkey = { path = "../../builtins/session/valkey", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs new file mode 100644 index 00000000..6206423b --- /dev/null +++ b/crates/cpex-builtins/src/lib.rs @@ -0,0 +1,185 @@ +// 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; + +// ----------------------------------------------------------------------------- +// Feature-gated re-exports of each builtin's factory + KIND +// ----------------------------------------------------------------------------- + +#[cfg(feature = "cedar-direct")] +pub use cpex_pdp_cedar_direct::CedarDirectPdpFactory; +#[cfg(feature = "cel")] +pub use cpex_pdp_cel::CelPdpFactory; +#[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 = "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}; + +// ----------------------------------------------------------------------------- +// Plugin-factory registration (by-kind axis) +// ----------------------------------------------------------------------------- + +/// 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 "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory, + feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory, +} + +// ----------------------------------------------------------------------------- +// PDP-factory and session-store axes +// ----------------------------------------------------------------------------- + +/// 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())); + 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 +} + +// ----------------------------------------------------------------------------- +// One-call install +// ----------------------------------------------------------------------------- + +/// 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); +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[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; + 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/src/factory.rs b/crates/cpex-core/src/factory.rs index 95297d67..f5c6bb5d 100644 --- a/crates/cpex-core/src/factory.rs +++ b/crates/cpex-core/src/factory.rs @@ -110,8 +110,16 @@ impl PluginFactoryRegistry { } /// 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) { - self.factories.insert(kind.into(), factory); + 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. diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index d77180ff..fd5544a8 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -21,23 +21,21 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] cpex-core = { path = "../cpex-core" } # APL governance layer — bundled so Go/Python hosts can enable APL -# policies, route handlers, and the standard plugin/PDP factories via -# the `cpex_apl_install` FFI entry point. Symbols survive in the -# staticlib because that entry point references each factory. +# policies and route handlers via the `cpex_apl_install` FFI entry point. apl-cpex = { path = "../apl-cpex" } -apl-pii-scanner = { path = "../apl-pii-scanner" } -apl-audit-logger = { path = "../apl-audit-logger" } -apl-identity-jwt = { path = "../apl-identity-jwt" } -apl-delegator-oauth = { path = "../apl-delegator-oauth" } -apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" } -# Heavy (~200 transitive deps via the Cedarling git dep); kept out of the -# default `.a` and behind the `cedarling` feature. -apl-cedarling = { path = "../apl-cedarling", optional = true } -# Valkey-backed SessionStore (redis client + rustls TLS stack). Optional -# and behind the `valkey` feature so the default `.a` artifact size is -# unaffected; default-members exclusion alone does NOT keep its object -# code out of a `-p cpex-ffi` build — the feature gate does. -apl-session-valkey = { path = "../apl-session-valkey", optional = 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 plus +# the cedar-direct PDP (note: NOT `cel`, matching the prior footprint). +cpex-builtins = { path = "../cpex-builtins", default-features = false, features = [ + "pii-scanner", + "audit-logger", + "identity-jwt", + "delegator-oauth", + "cedar-direct", +] } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -47,12 +45,10 @@ tracing = { workspace = true } [features] default = [] -# Opt-in Cedarling-backed identity + PDP. Build with -# `cargo build -p cpex-ffi --features cedarling`. -cedarling = ["dep:apl-cedarling"] -# Opt-in Valkey-backed SessionStore. Build with -# `cargo build -p cpex-ffi --features valkey`. -valkey = ["dep:apl-session-valkey"] +# Opt-in Valkey-backed SessionStore (redis client + rustls TLS stack). +# Routed through cpex-builtins so the `.a` artifact size is unaffected +# without the feature. Build with `cargo build -p cpex-ffi --features valkey`. +valkey = ["cpex-builtins/valkey"] [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md index 0430a34b..ef450bdb 100644 --- a/crates/cpex-ffi/RELEASE.md +++ b/crates/cpex-ffi/RELEASE.md @@ -13,9 +13,7 @@ an artifact, and the FFI ABI policy that makes the contract durable. > (`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`. The -> Cedarling-backed seams are **not** in the default `.a` — build with -> `cargo build -p cpex-ffi --features cedarling` to include them. +> `cpex_manager_new_default` and before `cpex_load_config`. ## What is published diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs index cfa1d07e..cd7a43bf 100644 --- a/crates/cpex-ffi/src/apl.rs +++ b/crates/cpex-ffi/src/apl.rs @@ -9,9 +9,11 @@ // installs the APL config visitor on a manager so that a subsequent // `cpex_load_config` walks `apl:` blocks and installs per-route handlers. // -// Registration is explicit (no inventory/ctor magic): each factory is -// referenced here so its object code survives in `libcpex_ffi.a`. Adding -// a new bundled factory means adding a `register_factory` call below. +// 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. // // Ordering: call AFTER `cpex_manager_new_default` and BEFORE // `cpex_load_config`. The config visitor must be registered before the @@ -26,7 +28,6 @@ use std::os::raw::c_int; use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::sync::Arc; use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC}; @@ -34,17 +35,17 @@ use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC}; /// visitor (in-process defaults: memory session store, default baseline /// capabilities) on `mgr`. /// -/// Bundled plugin factories (registered by `kind`): -/// - `validator/pii-scan` → apl-pii-scanner -/// - `audit/logger` → apl-audit-logger -/// - `identity/jwt` → apl-identity-jwt -/// - `delegator/oauth` → apl-delegator-oauth +/// 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 /// /// Bundled PDP factory (consulted for `global.apl.pdp[]` entries): -/// - `cedar-direct` → apl-pdp-cedar-direct +/// - `cedar-direct` → cedar-direct /// -/// With the `cedarling` cargo feature, the Cedarling-backed identity and -/// PDP seams are additionally wired. +/// With the `valkey` cargo feature, the Valkey-backed session store factory +/// is additionally wired. /// /// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or /// `RC_PANIC` if registration panicked (caught at the FFI boundary). @@ -62,42 +63,21 @@ pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int 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. - inner.manager.register_factory( - apl_pii_scanner::KIND, - Box::new(apl_pii_scanner::PiiScannerFactory), - ); - inner.manager.register_factory( - apl_audit_logger::KIND, - Box::new(apl_audit_logger::AuditLoggerFactory), - ); - inner.manager.register_factory( - apl_identity_jwt::KIND, - Box::new(apl_identity_jwt::JwtIdentityFactory), - ); - inner.manager.register_factory( - apl_delegator_oauth::KIND, - Box::new(apl_delegator_oauth::OAuthDelegatorFactory), - ); + // YAML `kind:` matches. Delegated to cpex-builtins, whose enabled + // feature set determines the bundle. + cpex_builtins::register_builtins(&inner.manager); - // APL config visitor + PDP factories. `pdp_factories` are consulted - // for `global.apl.pdp[]` entries; cedar-direct is the bundled - // default. The visitor keeps a Weak (see - // CpexManagerInner) that upgrades during load_config_yaml. + // APL config visitor + PDP / session-store factories. The factory + // sets are consulted for `global.apl.pdp[]` and + // `global.apl.session_store` entries; cedar-direct is the bundled + // PDP default and the Valkey store is wired when the `valkey` + // feature is on (otherwise the lists are empty and 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 = vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; - - // With the `valkey` cargo feature, register the Valkey - // SessionStore factory so a `global.apl.session_store: - // { kind: valkey, ... }` config block selects it. Without the - // feature, the default in-process MemorySessionStore stays active - // and no Valkey object code is linked. - #[cfg(feature = "valkey")] - { - opts.session_store_factories = vec![Arc::new( - apl_session_valkey::ValkeySessionStoreFactory::new(), - )]; - } + opts.pdp_factories = cpex_builtins::builtin_pdps(); + opts.session_store_factories = cpex_builtins::builtin_session_store_factories(); apl_cpex::register_apl(&inner.manager, opts); })); diff --git a/crates/cpex/Cargo.toml b/crates/cpex/Cargo.toml index 7139fec4..e41baea3 100644 --- a/crates/cpex/Cargo.toml +++ b/crates/cpex/Cargo.toml @@ -3,46 +3,47 @@ # SPDX-License-Identifier: Apache-2.0 # Authors: Fred Araujo # -# cpex — batteries-included host facade. +# cpex — host facade. # # One dependency instead of a dozen: re-exports the host runtime -# (PluginManager, AplOptions, register_apl) plus the bundled plugin -# factories, each gated behind a cargo feature. A host enables the -# plugins it wants and gets them through this single crate rather than -# pinning apl-cmf / apl-cpex / apl-pdp-* / apl-session-* individually. +# (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. # -# cpex = { version = "0.2.0", features = ["jwt", "oauth", "cedar", "cel", "valkey"] } +# cpex = { version = "0.2.0", features = ["builtins"] } # default builtin set +# cpex = { version = "0.2.0", features = ["jwt", "cedar"] } # a minimal subset # # then `cpex::install_builtins(&mgr)` registers every enabled factory and # installs the APL config visitor in one call. [package] name = "cpex" -description = "CPEX host facade — re-exports the runtime and feature-gated plugin factories." +description = "CPEX host facade — re-exports the runtime and (optionally) the feature-gated builtin extensions." version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [features] -# No plugins are forced on a re-export-only consumer, but the common -# in-process set is on by default so `cpex = "0.2"` is useful out of the -# box. Heavier / external-backend plugins (valkey, and future cedarling) -# stay opt-in, mirroring the workspace's default-members policy. -default = ["jwt", "oauth", "pii", "audit", "cedar", "cel"] +# 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 = [] -# Individual plugins, each pulling exactly one apl-* crate. -jwt = ["dep:apl-identity-jwt"] -oauth = ["dep:apl-delegator-oauth"] -pii = ["dep:apl-pii-scanner"] -audit = ["dep:apl-audit-logger"] -cedar = ["dep:apl-pdp-cedar-direct"] -cel = ["dep:apl-pdp-cel"] -valkey = ["dep:apl-session-valkey"] +builtins = ["cpex-builtins/default"] +full = ["cpex-builtins/full"] -# Everything the facade knows how to wire, including the Valkey session -# store (redis client + rustls TLS stack). -full = ["jwt", "oauth", "pii", "audit", "cedar", "cel", "valkey"] +# 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"] +cedar = ["cpex-builtins/cedar-direct"] +cel = ["cpex-builtins/cel"] +valkey = ["cpex-builtins/valkey"] [dependencies] # Host runtime — always present, this is the point of the facade. @@ -51,11 +52,7 @@ apl-core = { path = "../apl-core" } apl-cmf = { path = "../apl-cmf" } apl-cpex = { path = "../apl-cpex" } -# Bundled plugin factories — each behind its feature. -apl-identity-jwt = { path = "../apl-identity-jwt", optional = true } -apl-delegator-oauth = { path = "../apl-delegator-oauth", optional = true } -apl-pii-scanner = { path = "../apl-pii-scanner", optional = true } -apl-audit-logger = { path = "../apl-audit-logger", optional = true } -apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct", optional = true } -apl-pdp-cel = { path = "../apl-pdp-cel", optional = true } -apl-session-valkey = { path = "../apl-session-valkey", optional = 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 = { path = "../cpex-builtins", optional = true, default-features = false } diff --git a/crates/cpex/src/lib.rs b/crates/cpex/src/lib.rs index b5da2a1d..a41a68e3 100644 --- a/crates/cpex/src/lib.rs +++ b/crates/cpex/src/lib.rs @@ -5,41 +5,47 @@ //! CPEX host facade. //! -//! A single dependency that re-exports the CPEX host runtime and the -//! bundled APL plugin factories, each behind a cargo feature. Hosts -//! depend on this crate instead of pinning `apl-cmf`, `apl-cpex`, -//! `apl-pdp-*`, `apl-session-*`, and friends one by one. +//! A single dependency that re-exports the CPEX host runtime, so hosts +//! depend on this crate instead of pinning `apl-cmf`, `apl-cpex`, and +//! `cpex-core` one by one. +//! +//! By default this 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. //! //! # Usage //! -//! ```toml -//! cpex = { version = "0.2.0", features = ["jwt", "oauth", "cedar", "cel", "valkey"] } -//! ``` +//! Engine only (register your own factories): //! //! ```no_run //! use std::sync::Arc; //! use cpex::PluginManager; //! //! let mgr = Arc::new(PluginManager::default()); -//! // Register every enabled plugin factory and install the APL config +//! // ... 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. //! ``` //! -//! For finer control, the building blocks are public: -//! [`register_builtin_plugins`] registers the by-kind plugin factories, -//! [`builtin_pdp_factories`] / [`builtin_session_store_factories`] return -//! the enabled factories for an [`AplOptions`] you assemble yourself, and -//! every concrete factory type is re-exported under its feature. -//! //! # Features //! -//! `jwt`, `oauth`, `pii`, `audit`, `cedar`, `cel` are on by default. -//! `valkey` (Valkey-backed session store; pulls a redis client and a -//! rustls TLS stack) is opt-in. `full` enables everything. - -use std::sync::Arc; +//! 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). // ----------------------------------------------------------------------------- // Host runtime re-exports (always available) @@ -55,125 +61,50 @@ pub use apl_cpex::{ pub use cpex_core::manager::PluginManager; // ----------------------------------------------------------------------------- -// Bundled plugin factories (feature-gated) +// Bundled extensions (only when a builtins feature pulls in cpex-builtins) // ----------------------------------------------------------------------------- -#[cfg(feature = "audit")] -pub use apl_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; -#[cfg(feature = "oauth")] -pub use apl_delegator_oauth::{OAuthDelegatorFactory, KIND as OAUTH_KIND}; -#[cfg(feature = "jwt")] -pub use apl_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; +// The whole aggregator, for advanced use. +#[cfg(feature = "cpex-builtins")] +pub use cpex_builtins; + +// 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 apl_pdp_cedar_direct::CedarDirectPdpFactory; +pub use cpex_builtins::CedarDirectPdpFactory; #[cfg(feature = "cel")] -pub use apl_pdp_cel::CelPdpFactory; +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 apl_pii_scanner::{PiiScannerFactory, KIND as PII_KIND}; +pub use cpex_builtins::{PiiScannerFactory, PII_KIND}; #[cfg(feature = "valkey")] -pub use apl_session_valkey::{ValkeyConfig, ValkeySessionStoreFactory, KIND as VALKEY_KIND}; - -// ----------------------------------------------------------------------------- -// Registration helpers -// ----------------------------------------------------------------------------- - -/// Register every enabled by-kind plugin factory on `mgr`: identity -/// (`jwt`), delegators (`oauth`), validators (`pii`), and observers -/// (`audit`). 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_pdp_factories`] and -/// [`builtin_session_store_factories`], or use [`install_builtins`]. -#[allow(unused_variables)] -pub fn register_builtin_plugins(mgr: &Arc) { - #[cfg(feature = "jwt")] - mgr.register_factory(JWT_KIND, Box::new(JwtIdentityFactory)); - #[cfg(feature = "oauth")] - mgr.register_factory(OAUTH_KIND, Box::new(OAuthDelegatorFactory)); - #[cfg(feature = "pii")] - mgr.register_factory(PII_KIND, Box::new(PiiScannerFactory)); - #[cfg(feature = "audit")] - mgr.register_factory(AUDIT_KIND, Box::new(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_pdp_factories() -> Vec> { - let mut factories: Vec> = Vec::new(); - #[cfg(feature = "cedar")] - factories.push(Arc::new(CedarDirectPdpFactory::new())); - #[cfg(feature = "cel")] - factories.push(Arc::new(CelPdpFactory::new())); - factories -} - -/// The enabled session-store factories, ready to drop into -/// [`AplOptions::session_store_factories`]. A `global.session_store: -/// { kind: ... }` config block selects one; absent that, the -/// [`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(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_builtin_plugins`] and -/// [`AplOptions`] directly when you need to customize capabilities or the -/// default store. -pub fn install_builtins(mgr: &Arc) { - register_builtin_plugins(mgr); - - let mut opts = AplOptions::in_process(); - opts.pdp_factories = builtin_pdp_factories(); - opts.session_store_factories = builtin_session_store_factories(); - - let _visitor = register_apl(mgr, opts); -} +pub use cpex_builtins::{ValkeyConfig, ValkeySessionStoreFactory, VALKEY_KIND}; // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- -#[cfg(test)] +#[cfg(all(test, feature = "cpex-builtins"))] mod tests { use super::*; + use std::sync::Arc; #[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") as usize + cfg!(feature = "cel") as usize; - assert_eq!( - builtin_pdp_factories().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/deploy/valkey-compose.yml b/deploy/valkey-compose.yml index 8aab271b..b25fe54c 100644 --- a/deploy/valkey-compose.yml +++ b/deploy/valkey-compose.yml @@ -2,7 +2,7 @@ # Copyright 2026 # SPDX-License-Identifier: Apache-2.0 # -# Local development / integration Valkey for the apl-session-valkey +# 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): # @@ -12,7 +12,7 @@ # Usage: # docker compose -f deploy/valkey-compose.yml up -d # VALKEY_TEST_URL=redis://127.0.0.1:6379 \ -# cargo test -p apl-session-valkey --test valkey_store_integration -- --ignored +# cargo test -p cpex-session-valkey --test valkey_store_integration -- --ignored # # 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/operations/valkey-session-store.md b/docs/operations/valkey-session-store.md index fd8f689e..4050101f 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` (`apl-session-valkey`) persists per-session +The Valkey-backed `SessionStore` (`cpex-session-valkey`) 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 @@ -230,7 +230,7 @@ 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 apl-session-valkey --test valkey_store_integration -- --ignored + cargo test -p cpex-session-valkey --test valkey_store_integration -- --ignored ``` The compose file runs a `noeviction`-configured Valkey. It has no TLS/ACL and From b40d0510c37eb2d7303b75c417e5da926f1148f1 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 25 Jun 2026 07:29:59 +0200 Subject: [PATCH 21/64] chore: rust CI and refactored docs (#82) Signed-off-by: Frederico Araujo --- .cargo/config.toml | 4 + .coveragerc | 9 - .env.example | 178 - .github/dependabot.yml | 24 + .github/workflows/ci.yml | 83 +- .github/workflows/coverage.yaml | 41 + .github/workflows/lint.yml | 140 - .github/workflows/release.yaml | 99 + .github/workflows/supply-chain.yaml | 42 + .github/workflows/tests.yaml | 61 - .gitignore | 4 +- .yamllint | 3 +- CONTRIBUTING.md | 169 +- Cargo.toml | 157 + MANIFEST.in | 115 - Makefile | 699 +--- README.md | 404 +- SECURITY.md | 57 + builtins/pdps/cedar-direct/Cargo.toml | 17 +- builtins/pdps/cedar-direct/src/decision.rs | 7 +- builtins/pdps/cedar-direct/src/entities.rs | 5 +- builtins/pdps/cedar-direct/src/request.rs | 19 +- builtins/pdps/cedar-direct/src/resolver.rs | 36 +- builtins/pdps/cedar-direct/src/template.rs | 14 +- .../cedar-direct/tests/basic_allow_deny.rs | 21 +- .../cedar-direct/tests/small_stack_eval.rs | 3 +- builtins/pdps/cel/Cargo.toml | 17 +- builtins/pdps/cel/src/activation.rs | 23 +- builtins/pdps/cel/src/resolver.rs | 138 +- builtins/plugins/audit-logger/Cargo.toml | 11 +- builtins/plugins/audit-logger/src/factory.rs | 5 +- builtins/plugins/audit-logger/src/logger.rs | 10 +- builtins/plugins/delegator-biscuit/Cargo.toml | 13 +- .../plugins/delegator-biscuit/src/config.rs | 14 +- .../delegator-biscuit/src/delegator.rs | 38 +- .../delegator-biscuit/tests/biscuit_e2e.rs | 15 +- builtins/plugins/delegator-oauth/Cargo.toml | 13 +- .../plugins/delegator-oauth/src/config.rs | 9 +- .../plugins/delegator-oauth/src/delegator.rs | 34 +- .../delegator-oauth/tests/oauth_e2e.rs | 28 +- builtins/plugins/identity-jwt/Cargo.toml | 13 +- .../plugins/identity-jwt/src/claim_map.rs | 9 +- builtins/plugins/identity-jwt/src/config.rs | 23 +- builtins/plugins/identity-jwt/src/lib.rs | 4 +- builtins/plugins/identity-jwt/src/resolver.rs | 49 +- .../identity-jwt/tests/jwks_url_e2e.rs | 25 +- .../plugins/identity-jwt/tests/jwt_e2e.rs | 4 +- builtins/plugins/pii-scanner/Cargo.toml | 11 +- builtins/plugins/pii-scanner/src/factory.rs | 5 +- builtins/plugins/pii-scanner/src/scanner.rs | 73 +- builtins/session/valkey/Cargo.toml | 11 +- builtins/session/valkey/src/config.rs | 5 +- builtins/session/valkey/src/store.rs | 4 +- .../valkey/tests/valkey_store_integration.rs | 4 +- clippy.toml | 30 + cpex/__init__.py | 8 - cpex/framework/__init__.py | 192 - cpex/framework/base.py | 659 --- cpex/framework/cmf/__init__.py | 10 - cpex/framework/cmf/message.py | 941 ----- cpex/framework/cmf/view.py | 1230 ------ cpex/framework/constants.py | 47 - cpex/framework/decorator.py | 198 - cpex/framework/errors.py | 84 - cpex/framework/extensions/__init__.py | 76 - cpex/framework/extensions/agent.py | 99 - cpex/framework/extensions/completion.py | 105 - cpex/framework/extensions/constants.py | 97 - cpex/framework/extensions/delegation.py | 121 - cpex/framework/extensions/extensions.py | 95 - cpex/framework/extensions/framework.py | 54 - cpex/framework/extensions/http.py | 46 - cpex/framework/extensions/llm.py | 45 - cpex/framework/extensions/mcp.py | 152 - cpex/framework/extensions/meta.py | 62 - cpex/framework/extensions/provenance.py | 45 - cpex/framework/extensions/request.py | 54 - cpex/framework/extensions/security.py | 247 -- cpex/framework/extensions/tiers.py | 653 --- cpex/framework/external/__init__.py | 47 - cpex/framework/external/grpc/__init__.py | 63 - cpex/framework/external/grpc/client.py | 286 -- .../framework/external/grpc/proto/__init__.py | 19 - .../external/grpc/proto/plugin_service.proto | 146 - .../external/grpc/proto/plugin_service_pb2.py | 68 - .../grpc/proto/plugin_service_pb2.pyi | 203 - .../grpc/proto/plugin_service_pb2_grpc.py | 300 -- .../external/grpc/server/__init__.py | 15 - .../framework/external/grpc/server/runtime.py | 321 -- cpex/framework/external/grpc/server/server.py | 257 -- cpex/framework/external/grpc/tls_utils.py | 202 - cpex/framework/external/mcp/__init__.py | 11 - cpex/framework/external/mcp/client.py | 695 ---- .../framework/external/mcp/server/__init__.py | 14 - cpex/framework/external/mcp/server/runtime.py | 578 --- cpex/framework/external/mcp/server/server.py | 302 -- cpex/framework/external/mcp/tls_utils.py | 244 -- cpex/framework/external/proto_convert.py | 258 -- cpex/framework/external/unix/__init__.py | 15 - cpex/framework/external/unix/client.py | 355 -- cpex/framework/external/unix/protocol.py | 136 - .../external/unix/server/__init__.py | 12 - .../framework/external/unix/server/runtime.py | 74 - cpex/framework/external/unix/server/server.py | 419 -- cpex/framework/hooks/__init__.py | 9 - cpex/framework/hooks/agents.py | 164 - cpex/framework/hooks/http.py | 251 -- cpex/framework/hooks/identity.py | 293 -- cpex/framework/hooks/message.py | 152 - cpex/framework/hooks/policies.py | 134 - cpex/framework/hooks/prompts.py | 135 - cpex/framework/hooks/registry.py | 203 - cpex/framework/hooks/resources.py | 116 - cpex/framework/hooks/tools.py | 134 - cpex/framework/isolated/client.py | 345 -- cpex/framework/isolated/venv_comm.py | 279 -- cpex/framework/isolated/worker.py | 241 -- cpex/framework/loader/__init__.py | 11 - cpex/framework/loader/config.py | 100 - cpex/framework/loader/plugin.py | 193 - cpex/framework/manager.py | 1708 -------- cpex/framework/memory.py | 666 --- cpex/framework/models.py | 2445 ----------- cpex/framework/observability.py | 102 - cpex/framework/pdp/__init__.py | 33 - cpex/framework/pdp/authzen.py | 278 -- cpex/framework/pdp/base.py | 101 - cpex/framework/pdp/opa.py | 223 - cpex/framework/protocols.py | 67 - cpex/framework/registry.py | 218 - cpex/framework/settings.py | 699 ---- cpex/framework/utils.py | 518 --- cpex/framework/validators.py | 216 - cpex/templates/external/cookiecutter.json | 8 - .../.dockerignore | 363 -- .../.env.template | 126 - .../{{cookiecutter.plugin_slug}}/.ruff.toml | 63 - .../Containerfile | 58 - .../{{cookiecutter.plugin_slug}}/MANIFEST.in | 65 - .../{{cookiecutter.plugin_slug}}/Makefile | 494 --- .../{{cookiecutter.plugin_slug}}/README.md | 112 - .../pyproject.toml | 105 - .../resources/plugins/config.yaml | 34 - .../run-server.sh | 94 - .../tests/__init__.py | 0 .../tests/pytest.ini | 13 - .../test_{{cookiecutter.plugin_slug}}.py | 31 - .../{{cookiecutter.plugin_slug}}/__init__.py | 23 - .../plugin-manifest.yaml | 9 - .../{{cookiecutter.plugin_slug}}/plugin.py | 90 - cpex/templates/isolated/cookiecutter.json | 8 - .../{{cookiecutter.plugin_slug}}/README.md | 10 - .../{{cookiecutter.plugin_slug}}/__init__.py | 7 - .../{{cookiecutter.plugin_slug}}/config.yaml | 37 - .../plugin-manifest.yaml | 23 - .../{{cookiecutter.plugin_slug}}/plugin.py | 90 - .../requirements.txt | 7 - cpex/templates/native/cookiecutter.json | 8 - .../{{cookiecutter.plugin_slug}}/README.md | 10 - .../{{cookiecutter.plugin_slug}}/__init__.py | 7 - .../{{cookiecutter.plugin_slug}}/config.yaml | 34 - .../plugin-manifest.yaml | 9 - .../{{cookiecutter.plugin_slug}}/plugin.py | 90 - cpex/tools/README.md | 207 - cpex/tools/__init__.py | 8 - cpex/tools/catalog.py | 1822 --------- cpex/tools/cli.py | 923 ----- cpex/tools/integrity.py | 301 -- cpex/tools/models.py | 34 - cpex/tools/plugin_registry.py | 138 - cpex/tools/settings.py | 68 - crates/apl-cmf/Cargo.toml | 12 +- crates/apl-cmf/src/agent.rs | 33 +- crates/apl-cmf/src/capability_namespaces.rs | 4 - crates/apl-cmf/src/completion.rs | 22 +- crates/apl-cmf/src/custom.rs | 5 +- crates/apl-cmf/src/delegation.rs | 10 +- crates/apl-cmf/src/extensions_bridge.rs | 48 +- crates/apl-cmf/src/framework.rs | 16 +- crates/apl-cmf/src/http.rs | 25 +- crates/apl-cmf/src/lib.rs | 8 +- crates/apl-cmf/src/llm.rs | 8 +- crates/apl-cmf/src/mcp.rs | 55 +- crates/apl-cmf/src/meta.rs | 16 +- crates/apl-cmf/src/payload.rs | 14 +- crates/apl-cmf/src/provenance.rs | 12 +- crates/apl-cmf/src/request.rs | 20 +- crates/apl-cmf/src/security.rs | 25 +- crates/apl-cmf/tests/end_to_end.rs | 61 +- crates/apl-core/Cargo.toml | 10 +- crates/apl-core/src/attributes.rs | 28 +- crates/apl-core/src/evaluator.rs | 1219 ++++-- crates/apl-core/src/lib.rs | 2 +- crates/apl-core/src/parser.rs | 1002 +++-- crates/apl-core/src/pipeline.rs | 55 +- crates/apl-core/src/route.rs | 399 +- crates/apl-core/src/rules.rs | 197 +- crates/apl-core/src/step.rs | 29 +- crates/apl-core/tests/yaml_end_to_end.rs | 89 +- crates/apl-cpex/Cargo.toml | 15 +- crates/apl-cpex/src/cmf_invoker.rs | 4 +- crates/apl-cpex/src/delegation_invoker.rs | 10 +- crates/apl-cpex/src/dispatch_plan.rs | 32 +- crates/apl-cpex/src/parallel_safety.rs | 18 +- crates/apl-cpex/src/pdp_router.rs | 11 +- crates/apl-cpex/src/route_handler.rs | 22 +- crates/apl-cpex/src/visitor.rs | 42 +- crates/apl-cpex/tests/cmf_invoker_dispatch.rs | 4 +- crates/apl-cpex/tests/delegate_step_e2e.rs | 2 +- crates/apl-cpex/tests/end_to_end_route.rs | 2 +- crates/apl-cpex/tests/visitor_e2e.rs | 8 +- crates/cpex-builtins/Cargo.toml | 28 +- crates/cpex-core/Cargo.toml | 10 +- crates/cpex-core/src/cmf/content.rs | 12 +- crates/cpex-core/src/cmf/view.rs | 2 +- crates/cpex-core/src/config.rs | 90 +- crates/cpex-core/src/delegation/payload.rs | 17 +- crates/cpex-core/src/executor.rs | 66 +- .../cpex-core/src/extensions/authorization.rs | 10 +- crates/cpex-core/src/extensions/filter.rs | 13 +- .../src/extensions/raw_credentials.rs | 12 +- crates/cpex-core/src/hooks/metadata.rs | 58 +- crates/cpex-core/src/hooks/mod.rs | 4 +- crates/cpex-core/src/identity/payload.rs | 23 +- crates/cpex-core/src/manager.rs | 28 +- crates/cpex-core/src/registry.rs | 2 +- crates/cpex-core/tests/delegation_e2e.rs | 54 +- crates/cpex-core/tests/identity_e2e.rs | 35 +- crates/cpex-core/tests/identity_route_e2e.rs | 35 +- crates/cpex-ffi/Cargo.toml | 15 +- crates/cpex-ffi/src/apl.rs | 2 +- crates/cpex-ffi/src/lib.rs | 46 +- crates/cpex-orchestration/Cargo.toml | 8 + crates/cpex-orchestration/src/lib.rs | 21 +- crates/cpex-sdk/Cargo.toml | 10 +- crates/cpex/Cargo.toml | 18 +- deny.toml | 62 + docs/content/_index.md | 46 +- docs/content/docs/0.1.x/_index.md | 15 + .../content/docs/{ => 0.1.x}/api-reference.md | 2 + docs/content/docs/{ => 0.1.x}/cli.md | 2 + docs/content/docs/0.1.x/cmf.md | 167 + docs/content/docs/0.1.x/configuration.md | 222 + .../docs/{ => 0.1.x}/execution-modes.md | 4 +- docs/content/docs/0.1.x/extensions.md | 199 + .../docs/{ => 0.1.x}/external-plugins.md | 2 + docs/content/docs/{ => 0.1.x}/hook-types.md | 8 +- docs/content/docs/{ => 0.1.x}/hooks.md | 8 +- .../docs/{ => 0.1.x}/isolated-plugins.md | 4 +- docs/content/docs/0.1.x/overview.md | 45 + .../docs/{ => 0.1.x}/package-integrity.md | 2 + docs/content/docs/0.1.x/patterns.md | 256 ++ docs/content/docs/0.1.x/quickstart.md | 167 + docs/content/docs/0.1.x/testing.md | 233 ++ docs/content/docs/0.1.x/vision.md | 90 + docs/content/docs/_index.md | 6 +- docs/content/docs/apl/_index.md | 122 + docs/content/docs/apl/delegation.md | 72 + docs/content/docs/apl/effects.md | 69 + docs/content/docs/apl/identity.md | 59 + docs/content/docs/apl/pdp.md | 74 + docs/content/docs/apl/tainting.md | 75 + docs/content/docs/builtins.md | 56 + docs/content/docs/cmf.md | 172 +- docs/content/docs/configuration.md | 273 +- docs/content/docs/deployment.md | 51 + docs/content/docs/extensions.md | 247 +- docs/content/docs/overview.md | 76 +- docs/content/docs/patterns.md | 270 +- docs/content/docs/pipeline.md | 49 + docs/content/docs/quickstart.md | 176 +- docs/content/docs/reference.md | 32 + docs/content/docs/testing.md | 239 +- docs/content/docs/vision.md | 105 +- docs/static/images/cpex_overview.png | Bin 0 -> 359890 bytes docs/static/images/deployment.png | Bin 0 -> 233594 bytes examples/go-demo/ffi/Cargo.toml | 3 + examples/go-demo/ffi/src/demo_plugins.rs | 4 +- pyproject.toml | 167 - rust-toolchain.toml | 6 + rustfmt.toml | 12 + tests/__init__.py | 8 - tests/pytest.ini | 14 - tests/unit/__init__.py | 8 - tests/unit/cpex/__init__.py | 8 - tests/unit/cpex/conftest.py | 39 - tests/unit/cpex/fixtures/__init__.py | 8 - tests/unit/cpex/fixtures/common/__init__.py | 6 - tests/unit/cpex/fixtures/common/models.py | 92 - tests/unit/cpex/fixtures/common/policy.py | 35 - .../cpex/fixtures/configs/agent_context.yaml | 27 - .../cpex/fixtures/configs/agent_filter.yaml | 32 - .../fixtures/configs/agent_passthrough.yaml | 26 - .../configs/context_multiplugins.yaml | 42 - .../cpex/fixtures/configs/context_plugin.yaml | 28 - .../context_stdio_external_plugins.yaml | 25 - .../fixtures/configs/cross_hook_context.yaml | 26 - .../cpex/fixtures/configs/error_plugin.yaml | 28 - .../error_plugin_raise_error_false.yaml | 28 - .../configs/error_stdio_external_plugin.yaml | 20 - .../configs/extensions_aware_plugin.yaml | 24 - .../configs/extensions_custom_plugin.yaml | 22 - .../configs/extensions_label_plugin.yaml | 23 - .../configs/init_hooks_plugins_test.yaml | 93 - .../configs/invalid_single_plugin.yaml | 35 - .../fixtures/configs/isolated_plugin.yaml | 34 - .../configs/test_hook_patterns_config.yaml | 26 - .../configs/tool_headers_metadata_plugin.yaml | 28 - .../fixtures/configs/tool_headers_plugin.yaml | 28 - .../configs/valid_grpc_external_plugin.yaml | 18 - .../valid_grpc_external_plugin_manager.yaml | 21 - .../configs/valid_multiple_plugins.yaml | 55 - .../valid_multiple_plugins_filter.yaml | 87 - .../fixtures/configs/valid_no_plugin.yaml | 14 - .../configs/valid_single_filter_plugin.yaml | 34 - .../fixtures/configs/valid_single_plugin.yaml | 35 - .../valid_single_plugin_passthrough.yaml | 28 - .../configs/valid_stdio_external_plugin.yaml | 20 - ...valid_stdio_external_plugin_overrides.yaml | 23 - ...lid_stdio_external_plugin_passthrough.yaml | 20 - .../valid_stdio_external_plugin_regex.yaml | 20 - ...lid_strhttp_external_plugin_overrides.yaml | 23 - .../valid_strhttp_external_plugin_regex.yaml | 20 - .../fixtures/configs/valid_tool_hooks.yaml | 35 - .../configs/valid_unix_external_plugin.yaml | 21 - .../valid_unix_external_plugin_manager.yaml | 23 - tests/unit/cpex/fixtures/plugins/__init__.py | 6 - .../cpex/fixtures/plugins/agent_plugins.py | 184 - tests/unit/cpex/fixtures/plugins/context.py | 210 - .../fixtures/plugins/cross_hook_context.py | 244 -- .../unit/cpex/fixtures/plugins/deny_filter.py | 79 - tests/unit/cpex/fixtures/plugins/error.py | 104 - .../cpex/fixtures/plugins/extensions_aware.py | 81 - tests/unit/cpex/fixtures/plugins/headers.py | 227 -- .../plugins/isolated/test_plugin/plugin.py | 144 - .../isolated/test_plugin/requirements.txt | 1 - .../unit/cpex/fixtures/plugins/passthrough.py | 106 - .../cpex/fixtures/plugins/resource_filter.py | 260 -- .../cpex/fixtures/plugins/search_replace.py | 156 - tests/unit/cpex/fixtures/plugins/simple.py | 48 - tests/unit/cpex/framework/__init__.py | 6 - tests/unit/cpex/framework/cmf/__init__.py | 0 tests/unit/cpex/framework/cmf/test_message.py | 769 ---- tests/unit/cpex/framework/cmf/test_view.py | 1357 ------ .../cpex/framework/extensions/__init__.py | 0 .../framework/extensions/test_delegation.py | 134 - .../framework/extensions/test_extensions.py | 622 --- .../cpex/framework/extensions/test_tiers.py | 753 ---- .../unit/cpex/framework/external/__init__.py | 6 - .../cpex/framework/external/grpc/README.md | 115 - .../cpex/framework/external/grpc/__init__.py | 2 - .../framework/external/grpc/proto/__init__.py | 0 .../proto/test_plugin_service_pb2_grpc.py | 132 - .../external/grpc/server/__init__.py | 2 - .../external/grpc/server/test_runtime.py | 535 --- .../external/grpc/server/test_server.py | 525 --- .../framework/external/grpc/test_client.py | 562 --- .../external/grpc/test_client_integration.py | 410 -- .../external/grpc/test_grpc_models.py | 373 -- .../framework/external/grpc/test_tls_utils.py | 443 -- .../cpex/framework/external/mcp/__init__.py | 6 - .../framework/external/mcp/server/__init__.py | 6 - .../external/mcp/server/test_runtime.py | 386 -- .../mcp/server/test_runtime_coverage.py | 495 --- .../external/mcp/server/test_server.py | 445 -- .../mcp/test_client_certificate_validation.py | 482 --- .../external/mcp/test_client_config.py | 321 -- .../external/mcp/test_client_coverage.py | 902 ---- .../external/mcp/test_client_reconnect.py | 431 -- .../external/mcp/test_client_stdio.py | 336 -- .../mcp/test_client_streamable_http.py | 331 -- .../framework/external/mcp/test_tls_utils.py | 371 -- .../framework/external/test_proto_convert.py | 566 --- .../cpex/framework/external/unix/README.md | 145 - .../cpex/framework/external/unix/__init__.py | 2 - .../framework/external/unix/test_client.py | 661 --- .../external/unix/test_client_integration.py | 401 -- .../framework/external/unix/test_protocol.py | 299 -- .../framework/external/unix/test_runtime.py | 121 - .../framework/external/unix/test_server.py | 710 ---- .../framework/hooks/test_hook_patterns.py | 253 -- .../framework/hooks/test_hook_registry.py | 137 - tests/unit/cpex/framework/hooks/test_http.py | 562 --- .../cpex/framework/hooks/test_identity.py | 159 - .../unit/cpex/framework/hooks/test_message.py | 137 - tests/unit/cpex/framework/isolated/README.md | 199 - .../unit/cpex/framework/isolated/__init__.py | 10 - .../unit/cpex/framework/isolated/conftest.py | 145 - .../cpex/framework/isolated/test_client.py | 700 ---- .../framework/isolated/test_integration.py | 392 -- .../cpex/framework/isolated/test_venv_comm.py | 1376 ------- .../cpex/framework/isolated/test_worker.py | 452 -- tests/unit/cpex/framework/loader/__init__.py | 6 - .../framework/loader/test_plugin_loader.py | 315 -- .../framework/test_content_type_matching.py | 180 - tests/unit/cpex/framework/test_context.py | 157 - tests/unit/cpex/framework/test_errors.py | 71 - .../test_executor_context_concurrency.py | 263 -- .../framework/test_extensions_integration.py | 245 -- tests/unit/cpex/framework/test_manager.py | 556 --- .../cpex/framework/test_manager_coverage.py | 672 --- .../cpex/framework/test_manager_extended.py | 1184 ------ .../cpex/framework/test_manager_extensions.py | 201 - .../test_manager_runtime_disabled.py | 156 - tests/unit/cpex/framework/test_memory.py | 1592 -------- .../cpex/framework/test_models_http_fields.py | 57 - .../framework/test_models_package_version.py | 371 -- tests/unit/cpex/framework/test_models_tls.py | 118 - .../framework/test_models_user_context.py | 130 - .../unit/cpex/framework/test_observability.py | 388 -- tests/unit/cpex/framework/test_plugin_base.py | 87 - .../framework/test_plugin_base_coverage.py | 296 -- .../unit/cpex/framework/test_plugin_models.py | 830 ---- .../framework/test_plugin_models_coverage.py | 546 --- .../unit/cpex/framework/test_plugin_modes.py | 889 ---- tests/unit/cpex/framework/test_policies.py | 1170 ------ tests/unit/cpex/framework/test_registry.py | 351 -- .../cpex/framework/test_resource_hooks.py | 502 --- tests/unit/cpex/framework/test_settings.py | 491 --- .../framework/test_tenant_plugin_manager.py | 43 - tests/unit/cpex/framework/test_utils.py | 529 --- .../cpex/framework/test_utils_and_logic.py | 453 -- tests/unit/cpex/framework/test_validators.py | 312 -- tests/unit/cpex/tools/__init__.py | 0 tests/unit/cpex/tools/test_catalog.py | 3625 ----------------- tests/unit/cpex/tools/test_cli.py | 2031 --------- tests/unit/cpex/tools/test_integrity.py | 421 -- uv.lock | 2456 ----------- 428 files changed, 6698 insertions(+), 73975 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 .coveragerc delete mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/coverage.yaml delete mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/supply-chain.yaml delete mode 100644 .github/workflows/tests.yaml delete mode 100644 MANIFEST.in create mode 100644 SECURITY.md create mode 100644 clippy.toml delete mode 100644 cpex/__init__.py delete mode 100644 cpex/framework/__init__.py delete mode 100644 cpex/framework/base.py delete mode 100644 cpex/framework/cmf/__init__.py delete mode 100644 cpex/framework/cmf/message.py delete mode 100644 cpex/framework/cmf/view.py delete mode 100644 cpex/framework/constants.py delete mode 100644 cpex/framework/decorator.py delete mode 100644 cpex/framework/errors.py delete mode 100644 cpex/framework/extensions/__init__.py delete mode 100644 cpex/framework/extensions/agent.py delete mode 100644 cpex/framework/extensions/completion.py delete mode 100644 cpex/framework/extensions/constants.py delete mode 100644 cpex/framework/extensions/delegation.py delete mode 100644 cpex/framework/extensions/extensions.py delete mode 100644 cpex/framework/extensions/framework.py delete mode 100644 cpex/framework/extensions/http.py delete mode 100644 cpex/framework/extensions/llm.py delete mode 100644 cpex/framework/extensions/mcp.py delete mode 100644 cpex/framework/extensions/meta.py delete mode 100644 cpex/framework/extensions/provenance.py delete mode 100644 cpex/framework/extensions/request.py delete mode 100644 cpex/framework/extensions/security.py delete mode 100644 cpex/framework/extensions/tiers.py delete mode 100644 cpex/framework/external/__init__.py delete mode 100644 cpex/framework/external/grpc/__init__.py delete mode 100644 cpex/framework/external/grpc/client.py delete mode 100644 cpex/framework/external/grpc/proto/__init__.py delete mode 100644 cpex/framework/external/grpc/proto/plugin_service.proto delete mode 100644 cpex/framework/external/grpc/proto/plugin_service_pb2.py delete mode 100644 cpex/framework/external/grpc/proto/plugin_service_pb2.pyi delete mode 100644 cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py delete mode 100644 cpex/framework/external/grpc/server/__init__.py delete mode 100644 cpex/framework/external/grpc/server/runtime.py delete mode 100644 cpex/framework/external/grpc/server/server.py delete mode 100644 cpex/framework/external/grpc/tls_utils.py delete mode 100644 cpex/framework/external/mcp/__init__.py delete mode 100644 cpex/framework/external/mcp/client.py delete mode 100644 cpex/framework/external/mcp/server/__init__.py delete mode 100755 cpex/framework/external/mcp/server/runtime.py delete mode 100644 cpex/framework/external/mcp/server/server.py delete mode 100644 cpex/framework/external/mcp/tls_utils.py delete mode 100644 cpex/framework/external/proto_convert.py delete mode 100644 cpex/framework/external/unix/__init__.py delete mode 100644 cpex/framework/external/unix/client.py delete mode 100644 cpex/framework/external/unix/protocol.py delete mode 100644 cpex/framework/external/unix/server/__init__.py delete mode 100644 cpex/framework/external/unix/server/runtime.py delete mode 100644 cpex/framework/external/unix/server/server.py delete mode 100644 cpex/framework/hooks/__init__.py delete mode 100644 cpex/framework/hooks/agents.py delete mode 100644 cpex/framework/hooks/http.py delete mode 100644 cpex/framework/hooks/identity.py delete mode 100644 cpex/framework/hooks/message.py delete mode 100644 cpex/framework/hooks/policies.py delete mode 100644 cpex/framework/hooks/prompts.py delete mode 100644 cpex/framework/hooks/registry.py delete mode 100644 cpex/framework/hooks/resources.py delete mode 100644 cpex/framework/hooks/tools.py delete mode 100644 cpex/framework/isolated/client.py delete mode 100644 cpex/framework/isolated/venv_comm.py delete mode 100644 cpex/framework/isolated/worker.py delete mode 100644 cpex/framework/loader/__init__.py delete mode 100644 cpex/framework/loader/config.py delete mode 100644 cpex/framework/loader/plugin.py delete mode 100644 cpex/framework/manager.py delete mode 100644 cpex/framework/memory.py delete mode 100644 cpex/framework/models.py delete mode 100644 cpex/framework/observability.py delete mode 100644 cpex/framework/pdp/__init__.py delete mode 100644 cpex/framework/pdp/authzen.py delete mode 100644 cpex/framework/pdp/base.py delete mode 100644 cpex/framework/pdp/opa.py delete mode 100644 cpex/framework/protocols.py delete mode 100644 cpex/framework/registry.py delete mode 100644 cpex/framework/settings.py delete mode 100644 cpex/framework/utils.py delete mode 100644 cpex/framework/validators.py delete mode 100644 cpex/templates/external/cookiecutter.json delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/.dockerignore delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/.env.template delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/.ruff.toml delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/Containerfile delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/MANIFEST.in delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/Makefile delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/README.md delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/resources/plugins/config.yaml delete mode 100755 cpex/templates/external/{{cookiecutter.plugin_slug}}/run-server.sh delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/__init__.py delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/pytest.ini delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/test_{{cookiecutter.plugin_slug}}.py delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/__init__.py delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml delete mode 100644 cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin.py delete mode 100644 cpex/templates/isolated/cookiecutter.json delete mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md delete mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py delete mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml delete mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml delete mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py delete mode 100644 cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt delete mode 100644 cpex/templates/native/cookiecutter.json delete mode 100644 cpex/templates/native/{{cookiecutter.plugin_slug}}/README.md delete mode 100644 cpex/templates/native/{{cookiecutter.plugin_slug}}/__init__.py delete mode 100644 cpex/templates/native/{{cookiecutter.plugin_slug}}/config.yaml delete mode 100644 cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml delete mode 100644 cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin.py delete mode 100644 cpex/tools/README.md delete mode 100644 cpex/tools/__init__.py delete mode 100644 cpex/tools/catalog.py delete mode 100644 cpex/tools/cli.py delete mode 100644 cpex/tools/integrity.py delete mode 100644 cpex/tools/models.py delete mode 100644 cpex/tools/plugin_registry.py delete mode 100644 cpex/tools/settings.py create mode 100644 deny.toml create mode 100644 docs/content/docs/0.1.x/_index.md rename docs/content/docs/{ => 0.1.x}/api-reference.md (99%) rename docs/content/docs/{ => 0.1.x}/cli.md (99%) create mode 100644 docs/content/docs/0.1.x/cmf.md create mode 100644 docs/content/docs/0.1.x/configuration.md rename docs/content/docs/{ => 0.1.x}/execution-modes.md (98%) create mode 100644 docs/content/docs/0.1.x/extensions.md rename docs/content/docs/{ => 0.1.x}/external-plugins.md (99%) rename docs/content/docs/{ => 0.1.x}/hook-types.md (96%) rename docs/content/docs/{ => 0.1.x}/hooks.md (94%) rename docs/content/docs/{ => 0.1.x}/isolated-plugins.md (95%) create mode 100644 docs/content/docs/0.1.x/overview.md rename docs/content/docs/{ => 0.1.x}/package-integrity.md (99%) create mode 100644 docs/content/docs/0.1.x/patterns.md create mode 100644 docs/content/docs/0.1.x/quickstart.md create mode 100644 docs/content/docs/0.1.x/testing.md create mode 100644 docs/content/docs/0.1.x/vision.md create mode 100644 docs/content/docs/apl/_index.md create mode 100644 docs/content/docs/apl/delegation.md create mode 100644 docs/content/docs/apl/effects.md create mode 100644 docs/content/docs/apl/identity.md create mode 100644 docs/content/docs/apl/pdp.md create mode 100644 docs/content/docs/apl/tainting.md create mode 100644 docs/content/docs/builtins.md create mode 100644 docs/content/docs/deployment.md create mode 100644 docs/content/docs/pipeline.md create mode 100644 docs/content/docs/reference.md create mode 100644 docs/static/images/cpex_overview.png create mode 100644 docs/static/images/deployment.png delete mode 100644 pyproject.toml create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml delete mode 100644 tests/__init__.py delete mode 100644 tests/pytest.ini delete mode 100644 tests/unit/__init__.py delete mode 100644 tests/unit/cpex/__init__.py delete mode 100644 tests/unit/cpex/conftest.py delete mode 100644 tests/unit/cpex/fixtures/__init__.py delete mode 100644 tests/unit/cpex/fixtures/common/__init__.py delete mode 100644 tests/unit/cpex/fixtures/common/models.py delete mode 100644 tests/unit/cpex/fixtures/common/policy.py delete mode 100644 tests/unit/cpex/fixtures/configs/agent_context.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/agent_filter.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/agent_passthrough.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/context_multiplugins.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/context_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/context_stdio_external_plugins.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/cross_hook_context.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/error_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/error_plugin_raise_error_false.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/error_stdio_external_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/extensions_custom_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/extensions_label_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/init_hooks_plugins_test.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/invalid_single_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/isolated_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/tool_headers_metadata_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/tool_headers_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin_manager.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_single_filter_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_single_plugin_passthrough.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_overrides.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_passthrough.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_regex.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_overrides.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_tool_hooks.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml delete mode 100644 tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml delete mode 100644 tests/unit/cpex/fixtures/plugins/__init__.py delete mode 100644 tests/unit/cpex/fixtures/plugins/agent_plugins.py delete mode 100644 tests/unit/cpex/fixtures/plugins/context.py delete mode 100644 tests/unit/cpex/fixtures/plugins/cross_hook_context.py delete mode 100644 tests/unit/cpex/fixtures/plugins/deny_filter.py delete mode 100644 tests/unit/cpex/fixtures/plugins/error.py delete mode 100644 tests/unit/cpex/fixtures/plugins/extensions_aware.py delete mode 100644 tests/unit/cpex/fixtures/plugins/headers.py delete mode 100644 tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py delete mode 100644 tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt delete mode 100644 tests/unit/cpex/fixtures/plugins/passthrough.py delete mode 100644 tests/unit/cpex/fixtures/plugins/resource_filter.py delete mode 100644 tests/unit/cpex/fixtures/plugins/search_replace.py delete mode 100644 tests/unit/cpex/fixtures/plugins/simple.py delete mode 100644 tests/unit/cpex/framework/__init__.py delete mode 100644 tests/unit/cpex/framework/cmf/__init__.py delete mode 100644 tests/unit/cpex/framework/cmf/test_message.py delete mode 100644 tests/unit/cpex/framework/cmf/test_view.py delete mode 100644 tests/unit/cpex/framework/extensions/__init__.py delete mode 100644 tests/unit/cpex/framework/extensions/test_delegation.py delete mode 100644 tests/unit/cpex/framework/extensions/test_extensions.py delete mode 100644 tests/unit/cpex/framework/extensions/test_tiers.py delete mode 100644 tests/unit/cpex/framework/external/__init__.py delete mode 100644 tests/unit/cpex/framework/external/grpc/README.md delete mode 100644 tests/unit/cpex/framework/external/grpc/__init__.py delete mode 100644 tests/unit/cpex/framework/external/grpc/proto/__init__.py delete mode 100644 tests/unit/cpex/framework/external/grpc/proto/test_plugin_service_pb2_grpc.py delete mode 100644 tests/unit/cpex/framework/external/grpc/server/__init__.py delete mode 100644 tests/unit/cpex/framework/external/grpc/server/test_runtime.py delete mode 100644 tests/unit/cpex/framework/external/grpc/server/test_server.py delete mode 100644 tests/unit/cpex/framework/external/grpc/test_client.py delete mode 100644 tests/unit/cpex/framework/external/grpc/test_client_integration.py delete mode 100644 tests/unit/cpex/framework/external/grpc/test_grpc_models.py delete mode 100644 tests/unit/cpex/framework/external/grpc/test_tls_utils.py delete mode 100644 tests/unit/cpex/framework/external/mcp/__init__.py delete mode 100644 tests/unit/cpex/framework/external/mcp/server/__init__.py delete mode 100644 tests/unit/cpex/framework/external/mcp/server/test_runtime.py delete mode 100644 tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py delete mode 100644 tests/unit/cpex/framework/external/mcp/server/test_server.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_client_certificate_validation.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_client_config.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_client_coverage.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_client_reconnect.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_client_stdio.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_client_streamable_http.py delete mode 100644 tests/unit/cpex/framework/external/mcp/test_tls_utils.py delete mode 100644 tests/unit/cpex/framework/external/test_proto_convert.py delete mode 100644 tests/unit/cpex/framework/external/unix/README.md delete mode 100644 tests/unit/cpex/framework/external/unix/__init__.py delete mode 100644 tests/unit/cpex/framework/external/unix/test_client.py delete mode 100644 tests/unit/cpex/framework/external/unix/test_client_integration.py delete mode 100644 tests/unit/cpex/framework/external/unix/test_protocol.py delete mode 100644 tests/unit/cpex/framework/external/unix/test_runtime.py delete mode 100644 tests/unit/cpex/framework/external/unix/test_server.py delete mode 100644 tests/unit/cpex/framework/hooks/test_hook_patterns.py delete mode 100644 tests/unit/cpex/framework/hooks/test_hook_registry.py delete mode 100644 tests/unit/cpex/framework/hooks/test_http.py delete mode 100644 tests/unit/cpex/framework/hooks/test_identity.py delete mode 100644 tests/unit/cpex/framework/hooks/test_message.py delete mode 100644 tests/unit/cpex/framework/isolated/README.md delete mode 100644 tests/unit/cpex/framework/isolated/__init__.py delete mode 100644 tests/unit/cpex/framework/isolated/conftest.py delete mode 100644 tests/unit/cpex/framework/isolated/test_client.py delete mode 100644 tests/unit/cpex/framework/isolated/test_integration.py delete mode 100644 tests/unit/cpex/framework/isolated/test_venv_comm.py delete mode 100644 tests/unit/cpex/framework/isolated/test_worker.py delete mode 100644 tests/unit/cpex/framework/loader/__init__.py delete mode 100644 tests/unit/cpex/framework/loader/test_plugin_loader.py delete mode 100644 tests/unit/cpex/framework/test_content_type_matching.py delete mode 100644 tests/unit/cpex/framework/test_context.py delete mode 100644 tests/unit/cpex/framework/test_errors.py delete mode 100644 tests/unit/cpex/framework/test_executor_context_concurrency.py delete mode 100644 tests/unit/cpex/framework/test_extensions_integration.py delete mode 100644 tests/unit/cpex/framework/test_manager.py delete mode 100644 tests/unit/cpex/framework/test_manager_coverage.py delete mode 100644 tests/unit/cpex/framework/test_manager_extended.py delete mode 100644 tests/unit/cpex/framework/test_manager_extensions.py delete mode 100644 tests/unit/cpex/framework/test_manager_runtime_disabled.py delete mode 100644 tests/unit/cpex/framework/test_memory.py delete mode 100644 tests/unit/cpex/framework/test_models_http_fields.py delete mode 100644 tests/unit/cpex/framework/test_models_package_version.py delete mode 100644 tests/unit/cpex/framework/test_models_tls.py delete mode 100644 tests/unit/cpex/framework/test_models_user_context.py delete mode 100644 tests/unit/cpex/framework/test_observability.py delete mode 100644 tests/unit/cpex/framework/test_plugin_base.py delete mode 100644 tests/unit/cpex/framework/test_plugin_base_coverage.py delete mode 100644 tests/unit/cpex/framework/test_plugin_models.py delete mode 100644 tests/unit/cpex/framework/test_plugin_models_coverage.py delete mode 100644 tests/unit/cpex/framework/test_plugin_modes.py delete mode 100644 tests/unit/cpex/framework/test_policies.py delete mode 100644 tests/unit/cpex/framework/test_registry.py delete mode 100644 tests/unit/cpex/framework/test_resource_hooks.py delete mode 100644 tests/unit/cpex/framework/test_settings.py delete mode 100644 tests/unit/cpex/framework/test_tenant_plugin_manager.py delete mode 100644 tests/unit/cpex/framework/test_utils.py delete mode 100644 tests/unit/cpex/framework/test_utils_and_logic.py delete mode 100644 tests/unit/cpex/framework/test_validators.py delete mode 100644 tests/unit/cpex/tools/__init__.py delete mode 100644 tests/unit/cpex/tools/test_catalog.py delete mode 100644 tests/unit/cpex/tools/test_cli.py delete mode 100644 tests/unit/cpex/tools/test_integrity.py delete mode 100644 uv.lock diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..439c1189 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# Treat rustdoc warnings as errors everywhere (broken intra-doc links, etc.), +# matching the [workspace.lints.rustdoc] policy and the `make doc` target. +[build] +rustdocflags = ["-D", "warnings"] diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d7ea565d..00000000 --- a/.coveragerc +++ /dev/null @@ -1,9 +0,0 @@ -[run] -omit = - *__init__.py - */templates/* - -[report] -omit = - *__init__.py - */templates/* diff --git a/.env.example b/.env.example deleted file mode 100644 index c755e137..00000000 --- a/.env.example +++ /dev/null @@ -1,178 +0,0 @@ -# Enable the plugin framework -# PLUGINS_ENABLED=False - -# Default behavior for hooks without an explicit policy: 'allow' accepts all modifications (backwards compatible), 'deny' rejects all. Standard hooks always have explicit policies; this only affects custom hook types. Set to 'deny' for stricter production environments. -# Possible values: -# `allow`, `deny` -# PLUGINS_DEFAULT_HOOK_POLICY=allow - -# Path to plugins folder -# PLUGINS_FOLDER=plugins -# Path to main plugins configuration file -# PLUGINS_CONFIG_FILE=plugins/config.yaml - -### Plugin installation -# Comma Separated Values used by install with --type monorepo -# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins" - -# registry path -# PLUGIN_REGISTRY_FOLDER=data - -# Github API -# PLUGINS_GITHUB_API=api.github.com - -# PLUGINS_GITHUB_TOKEN= -### end Plugin installation - - -# Logging level for plugin framework components -# PLUGINS_LOG_LEVEL=INFO - -# Skip SSL certificate verification for plugin HTTP requests. WARNING: Only enable in dev environments with self-signed certificates. -# PLUGINS_SKIP_SSL_VERIFY=False - -# Enable SSRF protection for plugin endpoint URLs. Blocks private/reserved IP ranges (10.x, 172.16.x, 192.168.x, 127.x, 169.254.x). Disable for development or sidecar plugin configurations that use private IPs. -# PLUGINS_SSRF_PROTECTION_ENABLED=True - -# Plugin execution timeout in seconds -# PLUGINS_PLUGIN_TIMEOUT=30 - -# Globally halt the pipeline on any plugin error. Superseded by per-plugin on_error; prefer setting on_error: fail on individual plugins for finer control. -# PLUGINS_FAIL_ON_PLUGIN_ERROR=False - -# Maximum number of concurrent background tasks. Unlimited if None. -# PLUGINS_EXECUTION_POOL=10 - -# Maximum total concurrent HTTP connections for plugin requests -# PLUGINS_HTTPX_MAX_CONNECTIONS=200 - -# Maximum idle keepalive connections to retain (typically 50%% of max_connections) -# PLUGINS_HTTPX_MAX_KEEPALIVE_CONNECTIONS=100 - -# Seconds before idle keepalive connections are closed -# PLUGINS_HTTPX_KEEPALIVE_EXPIRY=30.0 - -# Timeout in seconds for establishing new connections (5s for LAN, increase for WAN) -# PLUGINS_HTTPX_CONNECT_TIMEOUT=5.0 - -# Timeout in seconds for reading response data (set high for slow MCP tool calls) -# PLUGINS_HTTPX_READ_TIMEOUT=120.0 - -# Timeout in seconds for writing request data -# PLUGINS_HTTPX_WRITE_TIMEOUT=30.0 - -# Timeout in seconds waiting for a connection from the pool (fail fast on exhaustion) -# PLUGINS_HTTPX_POOL_TIMEOUT=10.0 - -# Enable shell auto-completion for the mcpplugins CLI -# PLUGINS_CLI_COMPLETION=False - -# Markup renderer for CLI output (rich, markdown, or disabled) -# PLUGINS_CLI_MARKUP_MODE= - -# Path to PEM client certificate for mTLS -# PLUGINS_CLIENT_MTLS_CERTFILE= - -# Path to PEM client private key for mTLS -# PLUGINS_CLIENT_MTLS_KEYFILE= - -# Path to CA bundle for client certificate verification -# PLUGINS_CLIENT_MTLS_CA_BUNDLE= - -# Password for encrypted client private key -# PLUGINS_CLIENT_MTLS_KEYFILE_PASSWORD= - -# Verify the upstream server certificate -# PLUGINS_CLIENT_MTLS_VERIFY= - -# Enable hostname verification -# PLUGINS_CLIENT_MTLS_CHECK_HOSTNAME= - -# Path to PEM server private key -# PLUGINS_SERVER_SSL_KEYFILE= - -# Path to PEM server certificate -# PLUGINS_SERVER_SSL_CERTFILE= - -# Path to CA certificates for client verification -# PLUGINS_SERVER_SSL_CA_CERTS= - -# Password for encrypted server private key -# PLUGINS_SERVER_SSL_KEYFILE_PASSWORD= - -# Client certificate requirement (0=NONE, 1=OPTIONAL, 2=REQUIRED) -# PLUGINS_SERVER_SSL_CERT_REQS= - -# MCP server host to bind to -# PLUGINS_SERVER_HOST= - -# MCP server port to bind to -# PLUGINS_SERVER_PORT= - -# Unix domain socket path for MCP streamable HTTP -# PLUGINS_SERVER_UDS= - -# Enable SSL/TLS for the MCP server -# PLUGINS_SERVER_SSL_ENABLED= - -# Path to plugin configuration file for external servers -# PLUGINS_CONFIG_PATH= - -# Transport type for external MCP server (http, stdio) -# PLUGINS_TRANSPORT= - -# Path to PEM client certificate for gRPC mTLS -# PLUGINS_GRPC_CLIENT_MTLS_CERTFILE= - -# Path to PEM client private key for gRPC mTLS -# PLUGINS_GRPC_CLIENT_MTLS_KEYFILE= - -# Path to CA bundle for gRPC client verification -# PLUGINS_GRPC_CLIENT_MTLS_CA_BUNDLE= - -# Password for encrypted gRPC client private key -# PLUGINS_GRPC_CLIENT_MTLS_KEYFILE_PASSWORD= - -# Verify the gRPC upstream server certificate -# PLUGINS_GRPC_CLIENT_MTLS_VERIFY= - -# Path to PEM gRPC server private key -# PLUGINS_GRPC_SERVER_SSL_KEYFILE= - -# Path to PEM gRPC server certificate -# PLUGINS_GRPC_SERVER_SSL_CERTFILE= - -# Path to CA certificates for gRPC client verification -# PLUGINS_GRPC_SERVER_SSL_CA_CERTS= - -# Password for encrypted gRPC server private key -# PLUGINS_GRPC_SERVER_SSL_KEYFILE_PASSWORD= - -# gRPC client certificate requirement (none, optional, require) -# PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH= - -# gRPC server host to bind to -# PLUGINS_GRPC_SERVER_HOST= - -# gRPC server port to bind to -# PLUGINS_GRPC_SERVER_PORT= - -# Unix domain socket path for gRPC server -# PLUGINS_GRPC_SERVER_UDS= - -# Enable SSL/TLS for the gRPC server -# PLUGINS_GRPC_SERVER_SSL_ENABLED= - - - -### Package Integrity Verification -# Enable SHA256 hash verification for PyPI packages (default: True) -# When enabled, downloaded packages are verified against hashes from PyPI's JSON API -# Recommended: Keep enabled for security -# PLUGINS_VERIFY_PACKAGE_INTEGRITY=True - -# Strict integrity mode (default: False) -# When True: Fail installation if package hashes are unavailable -# When False: Warn but continue if hashes are unavailable -# Recommended: False for development, True for production -# PLUGINS_STRICT_INTEGRITY_MODE=False diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..fb260b3d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + # Rust dependencies across the Cargo workspace. + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + open-pull-requests-limit: 10 + groups: + cargo-minor-patch: + update-types: + - minor + - patch + + # GitHub Actions used by the workflows. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec838293..febbeb87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # =============================================================== -# CI - Main Continuous Integration Pipeline +# CI - Rust lint, test, examples, and docs gate # =============================================================== name: CI @@ -10,22 +10,93 @@ on: pull_request: types: [opened, synchronize, ready_for_review] branches: ["main"] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + jobs: lint: - name: Lint & Static Analysis - uses: ./.github/workflows/lint.yml + name: Lint (fmt + clippy) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - name: rustfmt + run: cargo fmt --all -- --check + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + unused-deps: + name: Unused deps (advisory) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 15 + # Advisory only: cargo-machete static analysis false-positives on + # macro/derive-only crates (thiserror, tracing, serde, async-trait). Surfaced + # as a non-blocking signal until the reports are triaged in a follow-up. + continue-on-error: true + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Install cargo-machete + run: cargo install cargo-machete --locked + - run: cargo machete test: - name: Tests - uses: ./.github/workflows/tests.yaml + name: Test (workspace) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - uses: Swatinem/rust-cache@v2 + - name: cargo test + # Valkey/testcontainers integration tests are `#[ignore]`d by default + # and run out-of-band, so this needs no Docker daemon. + run: cargo test --workspace + + examples: + name: Examples build (Rust + Go FFI) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.4" + - 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. + - name: make examples-build + run: make examples-build docs: name: Docs Build diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 00000000..51d66cfd --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,41 @@ +# =============================================================== +# Coverage - report-only (no gate). Surfaces line/region coverage +# via cargo-llvm-cov; does NOT fail the build on a threshold yet. +# Add `--fail-under-lines N` here and to `make coverage` to enforce +# a gate once the Rust port has settled. +# =============================================================== + +name: Coverage + +on: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, ready_for_review] + branches: ["main"] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + coverage: + name: cargo-llvm-cov (report only) + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - name: Install cargo-llvm-cov + run: cargo install cargo-llvm-cov --locked + - name: Coverage summary + run: cargo llvm-cov --workspace --summary-only | tee "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index f4f84c88..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,140 +0,0 @@ -# =============================================================== -# Lint & Static Analysis - Code Quality Gate -# =============================================================== -# -# - Lints both cpex/ in a unified workflow -# - Python linters run per-target; repo-wide checks run once -# - Each job installs the project in dev-editable mode -# --------------------------------------------------------------- - -name: Lint & Static Analysis - -on: - workflow_call: - push: - branches: ["main"] - paths: - - "cpex/**" - - "pyproject.toml" - - ".github/workflows/lint.yml" - pull_request: - types: [opened, synchronize, ready_for_review] - branches: ["main"] - paths: - - "cpex/**" - - "pyproject.toml" - - ".github/workflows/lint.yml" - -permissions: - contents: read - -jobs: - # --------------------------------------------------------------- - # Python linters - run on both cpex/ - # --------------------------------------------------------------- - python-lint: - if: github.event_name != 'pull_request' || !github.event.pull_request.draft - strategy: - fail-fast: false - matrix: - target: [cpex] - tool: - - id: ruff - setup: pip install ruff - cmd: "ruff check $TARGET" - - id: vulture - setup: pip install vulture - cmd: 'vulture $TARGET' - - id: interrogate - setup: pip install interrogate - cmd: "interrogate -vv $TARGET" - - id: radon - setup: pip install radon - cmd: "radon cc $TARGET --min C --show-complexity && radon mi $TARGET --min B" - - name: "${{ matrix.tool.id }} (${{ matrix.target }})" - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout source - uses: actions/checkout@v5 - with: - fetch-depth: 1 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: pip - - - name: Set up uv - uses: astral-sh/setup-uv@v6 - - - name: Install project (editable mode) - run: | - python3 -m pip install --upgrade pip - pip install -e .[dev] - - - name: Install tool - run: ${{ matrix.tool.setup }} - - - name: Run linter - env: - TARGET: ${{ matrix.target }} - run: ${{ matrix.tool.cmd }} - - # --------------------------------------------------------------- - # Repo-wide syntax/format checkers (run once, not per-target) - # --------------------------------------------------------------- - syntax-check: - if: github.event_name != 'pull_request' || !github.event.pull_request.draft - strategy: - fail-fast: false - matrix: - include: - - id: yamllint - setup: pip install yamllint - cmd: | - find . -type f \( -name '*.yml' -o -name '*.yaml' \) \ - -not -path './cpex/templates/*' -print0 | - xargs -0 yamllint -c .yamllint - - - id: jsonlint - setup: | - sudo apt-get update -qq - sudo apt-get install -y jq - cmd: | - find . -type f -name '*.json' -not -path './node_modules/*' \ - -not -path './cpex/templates/*' -print0 | - xargs -0 -I{} jq empty "{}" - - - id: tomllint - setup: pip install tomlcheck - cmd: | - find . -type f -name '*.toml' \ - -not -path './cpex/templates/*' \ - -print0 | - xargs -0 -I{} tomlcheck "{}" - - name: ${{ matrix.id }} - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout source - uses: actions/checkout@v5 - with: - fetch-depth: 1 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: pip - - - name: Install tool - run: ${{ matrix.setup }} - - - name: Run check - run: ${{ matrix.cmd }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..4adac76f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,99 @@ +# =============================================================== +# Release - publish the CPEX library crates to crates.io +# =============================================================== +# +# 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. +# +# `cpex-ffi` is `publish = false` (distributed as signed prebuilt +# artifacts by release-ffi.yaml), so it is intentionally absent below. +# +# Requires repo secret: CARGO_REGISTRY_TOKEN. + +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate tag matches workspace version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - name: Check tag == workspace version + if: github.event_name == 'push' + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + TAG_VERSION="${TAG#v}" + CARGO_VERSION="$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name=="cpex") | .version')" + echo "tag=$TAG_VERSION workspace=$CARGO_VERSION" + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag ($TAG_VERSION) does not match workspace version ($CARGO_VERSION)" + exit 1 + fi + + test: + name: Test before publish + needs: [validate] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + + publish: + name: Publish to crates.io + needs: [validate, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - uses: Swatinem/rust-cache@v2 + - name: cargo publish (dependency order) + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + set -euo pipefail + # 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-pdp-cedar-direct + cpex-pdp-cel + cpex-session-valkey + cpex-builtins + cpex + ) + for c in "${crates[@]}"; do + echo "::group::publish $c" + cargo publish -p "$c" --locked + echo "::endgroup::" + sleep 15 + done diff --git a/.github/workflows/supply-chain.yaml b/.github/workflows/supply-chain.yaml new file mode 100644 index 00000000..ae18b65a --- /dev/null +++ b/.github/workflows/supply-chain.yaml @@ -0,0 +1,42 @@ +# =============================================================== +# Supply Chain - cargo-deny: advisories (RustSec), licenses, bans, +# and crate sources. Single source of truth: deny.toml. +# +# (cargo-deny's advisory check covers the same RustSec database as +# cargo-audit, plus license/ban/source policy, so we run just deny +# and keep one ignore list.) +# =============================================================== + +name: Supply Chain + +on: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, ready_for_review] + branches: ["main"] + schedule: + # Catch newly published advisories against the locked tree. + - cron: "27 6 * * 1" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + deny: + name: cargo deny + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.96.0 + uses: dtolnay/rust-toolchain@1.96.0 + - uses: Swatinem/rust-cache@v2 + - name: Install cargo-deny + run: cargo install cargo-deny --locked + - run: cargo deny check diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml deleted file mode 100644 index 96ffa61a..00000000 --- a/.github/workflows/tests.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# =============================================================== -# Tests - Run the test suite -# =============================================================== - -name: Tests - -on: - workflow_call: - push: - branches: ["main"] - paths: - - "cpex/**" - - "tests/**" - - "pyproject.toml" - - ".github/workflows/tests.yaml" - pull_request: - types: [opened, synchronize, ready_for_review] - branches: ["main"] - paths: - - "cpex/**" - - "tests/**" - - "pyproject.toml" - - ".github/workflows/tests.yaml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - if: github.event_name != 'pull_request' || !github.event.pull_request.draft - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest] - - name: "Python ${{ matrix.python-version }}" - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - steps: - - name: Checkout source - uses: actions/checkout@v5 - with: - fetch-depth: 1 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install dependencies - run: | - python3 -m pip install --upgrade pip - pip install -e ".[dev,all]" - - - name: Run tests - run: | - PYTHONPATH=cpex pytest -n auto tests diff --git a/.gitignore b/.gitignore index 82f0d5ea..88099657 100644 --- a/.gitignore +++ b/.gitignore @@ -274,4 +274,6 @@ db_path/ tmp/ .continue -plugin-catalog \ No newline at end of file +plugin-catalog +# Hugo build output +docs/public/ diff --git a/.yamllint b/.yamllint index 8eeecc5c..431a6555 100644 --- a/.yamllint +++ b/.yamllint @@ -5,6 +5,5 @@ rules: level: warning # keep it a warning (default); use "error" to fail ignore: | .cache/** - cpex/templates/**/*.yaml - cpex/templates/**/*.yml + target/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ce07a9b..e2de9b52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,115 +1,102 @@ -# CONTRIBUTING +# Contributing to CPEX -## Contributing In General +CPEX welcomes external contributions. If you have an itch, please feel free to +scratch it. -Our project welcomes external contributions. If you have an itch, please feel -free to scratch it. +To contribute code or documentation, submit a [pull request](https://github.com/contextforge-org/cpex/pulls). +A good way to get familiar with the codebase is to tackle low-hanging fruit in +the [issue tracker](https://github.com/contextforge-org/cpex/issues). Before a +more ambitious contribution, please [open an issue](https://github.com/contextforge-org/cpex/issues) +first so the approach can be discussed — we want to avoid a situation where a +contribution requires extensive rework or cannot be accepted. -To contribute code or documentation, please submit a [pull request](https://github.com/contextforge-org/cpex/pulls). +> **Note:** This branch (`0.2`+) is the **Rust** substrate. The legacy Python +> package is maintained on the [`0.1.x` branch](https://github.com/contextforge-org/cpex/tree/0.1.x); +> send Python fixes there. -A good way to familiarize yourself with the codebase and contribution process is -to look for and tackle low-hanging fruit in the [issue tracker](https://github.com/contextforge-org/cpex/issues). -Before embarking on a more ambitious contribution, please quickly [get in touch](#communication) with us. +## Prerequisites -**Note: We appreciate your effort, and want to avoid a situation where a contribution -requires extensive rework (by you or by us), sits in backlog for a long time, or -cannot be accepted at all!** +- **Rust 1.96** — pinned in [`rust-toolchain.toml`](rust-toolchain.toml); `rustup` + installs it (with `clippy`, `rustfmt`, `llvm-tools-preview`) automatically. +- **Go 1.25+** — only needed to build/test the Go bindings (`go/cpex`) and the + Go demo (`examples/go-demo`). -### Proposing new features +## Development workflow -If you would like to implement a new feature, please [raise an issue](https://github.com/contextforge-org/cpex/issues) -before sending a pull request so the feature can be discussed. This is to avoid -you wasting your valuable time working on a feature that the project developers -are not interested in accepting into the code base. +The [`Makefile`](Makefile) mirrors CI — a green `make ci` locally means a green +pipeline: -### Fixing bugs - -If you would like to fix a bug, please [raise an issue](https://github.com/contextforge-org/cpex/issues) before sending a -pull request so it can be tracked. - -### Merge approval - -The project maintainers use LGTM (Looks Good To Me) in comments on the code -review to indicate acceptance. A change requires LGTMs from two of the -maintainers of each component affected. - -For a list of the maintainers, see the [MAINTAINERS.md](MAINTAINERS.md) page. - -## Legal - -Each source file must include a license header for the Apache -Software License 2.0. Using the SPDX format is the simplest approach. -e.g. - -```python -# Copyright All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 +```bash +make lint # rustfmt --check + clippy -D warnings +make test # cargo test --workspace +make audit # cargo deny check (advisories, licenses, bans, sources) +make coverage # coverage summary (report only, no gate) +make examples-build # build all Rust + Go examples — cheapest stale-API guard +make ci # the full gate: lint + test + examples ``` -We have tried to make it as easy as possible to make contributions. This -applies to how we handle the legal aspects of contribution. We use the -same approach - the [Developer's Certificate of Origin 1.1 (DCO)](https://github.com/hyperledger/fabric/blob/master/docs/source/DCO1.1.txt) - that the Linux(r) Kernel [community](https://elinux.org/Developer_Certificate_Of_Origin) -uses to manage code contributions. +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. +- **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 + [`Cargo.toml`](Cargo.toml); each crate opts in with `[lints] workspace = true`. + CI enforces it via `cargo clippy --workspace --all-targets -- -D warnings`. + - Prefer `#[expect(..., reason = "…")]` over `#[allow(...)]` where practical. + - Use `thiserror` for error types and `tracing` for runtime logging. + - Use workspace dependencies (`x = { workspace = true }`) to keep versions + consistent. +- **Lint ratchet:** many high-value lints (e.g. `missing_docs`, `doc_markdown`, + `unwrap_used`, `uninlined_format_args`) are currently parked at `allow` with a + `ratchet:` note because the pre-existing tree doesn't yet satisfy them. New + code is encouraged to meet the higher bar; tightening a parked lint to `deny` + (often a one-shot `cargo clippy --fix`) is a welcome focused PR. + +### Source file headers + +Each source file should carry an Apache-2.0 SPDX header. For Rust: + +```rust +// Location: ./path/to/file.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Your Name +``` -We simply ask that when submitting a patch for review, the developer -must include a sign-off statement in the commit message. +## crates.io publishing -Here is an example Signed-off-by line, which indicates that the -submitter accepts the DCO: +The library crates publish to crates.io from the `release.yaml` workflow on a +`vX.Y.Z` tag, in dependency order. Internal dependencies carry both a `path` +and a `version`; when adding a new internal dependency, include the `version` so +the crate remains publishable. `cpex-ffi` is `publish = false` (distributed as +signed prebuilt artifacts). -```text -Signed-off-by: John Doe -``` +## Legal — Developer Certificate of Origin -You can include this automatically when you commit a change to your -local git repository using the following command: +Contributions are accepted under the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org/). +Sign off every commit to certify you wrote the patch or otherwise have the right +to submit it under the project's license: ```bash git commit -s ``` -## Communication - -Please feel free to connect with us through the [issue tracker](https://github.com/contextforge-org/cpex/issues). +This adds a `Signed-off-by` trailer: -## Setup - -For setup instructions, please see the [Quick Start sections](README.md#quick-start---pypi) in the README, or refer to the [Installation](README.md#installation) section for detailed instructions. - -## Testing - -Before submitting changes, run the test suite as outlined in the [Bug-fix PR template](.github/PULL_REQUEST_TEMPLATE/bug_fix.md): - -1. `make lint` - passes all linters -2. `make test` - all unit + integration tests green -3. `make coverage` - ≥ 90% - -## Coding style guidelines - -- **Python >= 3.11** with type hints -- **Formatting**: Black (line length 200), isort (profile=black) -- **Linting**: Ruff, Pylint per `pyproject.toml` -- **Naming**: `snake_case` functions, `PascalCase` classes, `UPPER_CASE` constants - -See [CLAUDE.md](CLAUDE.md#code-style--standards) for complete coding standards. - -### Python File Headers - -All Python source files (`.py`) must begin with the following standardized header. This ensures consistency and proper licensing across the codebase. +```text +Signed-off-by: Jane Doe +``` -The header format is as follows: +## Security -```python -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Module Description. -Location: ./path/to/your/file.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: "Author One, Author Two" +Do not report security vulnerabilities through public issues or PRs. See +[SECURITY.md](SECURITY.md) for private disclosure via GitHub's vulnerability +reporting. -Your detailed module documentation begins here... -""" -``` +## Communication -You can automatically check and fix file headers using the provided `make` targets. For detailed usage and examples, please see the [File Header Management section](docs/docs/development/module-documentation.md) in our development documentation. +Connect with us through the [issue tracker](https://github.com/contextforge-org/cpex/issues). diff --git a/Cargo.toml b/Cargo.toml index 9865edda..9be88752 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,8 +60,14 @@ default-members = [ [workspace.package] version = "0.2.0" edition = "2021" +# MSRV — keep in sync with rust-toolchain.toml `channel` and clippy.toml `msrv`. +rust-version = "1.96" license = "Apache-2.0" authors = ["Teryl Taylor", "Fred Araujo"] +repository = "https://github.com/contextforge-org/cpex" +homepage = "https://contextforge-org.github.io/cpex/" +keywords = ["ai", "agent", "security", "policy", "plugin"] +categories = ["development-tools"] [workspace.dependencies] # Minimal tokio feature floor for crates that take `tokio = { workspace = true }` @@ -104,3 +110,154 @@ opt-level = "z" # optimize for size lto = true # cross-crate inlining + dead-code elimination codegen-units = 1 # maximize optimization (one unit, no parallel-codegen bloat) strip = true # drop symbols + debug info from the artifact + +# ============================================================================= +# Workspace lint policy +# ============================================================================= +# +# Ported from the praxis Rust project. Each member crate opts in with +# `[lints] workspace = true`, and CI + `make lint` enforce the wall via +# `cargo clippy --workspace --all-targets -- -D warnings`. +# +# Two tiers: +# * "deny" — enforced now; the current tree is clean against these, so any +# NEW violation fails CI. +# * "allow" with a `ratchet:` note — valuable lints the pre-existing code does +# not yet satisfy (≈1900 sites, dominated by doc_markdown and +# uninlined_format_args). Parked to keep this tooling PR free of +# mechanical churn; tighten to "deny" in focused follow-up PRs +# (most are `cargo clippy --fix`-able one lint at a time). +[workspace.lints.rust] +# --- Enforced --- +unused_imports = "deny" +unused_variables = "deny" +trivial_numeric_casts = "deny" +macro_use_extern_crate = "deny" +unused_macro_rules = "deny" +meta_variable_misuse = "deny" +keyword_idents_2024 = "deny" +non_ascii_idents = "deny" +noop_method_call = "deny" +redundant_lifetimes = "deny" +# --- Intentional / parked --- +unsafe_code = "allow" # cpex-ffi is an intentional `unsafe extern "C"` boundary +missing_debug_implementations = "allow" # many types wrap Box without Debug bounds +missing_docs = "allow" # ratchet: document public items, then deny +dead_code = "allow" # ratchet +unreachable_pub = "allow" # ratchet +unused_extern_crates = "allow" # ratchet +unused_qualifications = "allow" # ratchet +unused_mut = "allow" # ratchet +private_interfaces = "allow" # ratchet + +[workspace.lints.clippy] +# --- Enforced: async / concurrency safety --- +await_holding_lock = "deny" +await_holding_refcell_ref = "deny" +let_underscore_future = "deny" +# --- Enforced: memory / allocation efficiency --- +implicit_clone = "deny" +inefficient_to_string = "deny" +large_enum_variant = "deny" +vec_init_then_push = "deny" +# --- Enforced: idiomatic Rust --- +checked_conversions = "deny" +cloned_instead_of_copied = "deny" +explicit_into_iter_loop = "deny" +flat_map_option = "deny" +from_iter_instead_of_collect = "deny" +manual_instant_elapsed = "deny" +manual_ok_or = "deny" +unnecessary_get_then_check = "deny" +# --- Enforced: borrow / clarity --- +needless_borrow = "deny" +match_bool = "deny" +mut_mut = "deny" +needless_for_each = "deny" +range_plus_one = "deny" +redundant_else = "deny" +semicolon_if_nothing_returned = "deny" +# --- Enforced: import hygiene --- +enum_glob_use = "deny" +macro_use_imports = "deny" +# --- Enforced: code clarity --- +same_functions_in_if_condition = "deny" +stable_sort_primitive = "deny" +string_add = "deny" +string_lit_as_bytes = "deny" +trait_duplication_in_bounds = "deny" +type_repetition_in_bounds = "deny" +zero_sized_map_values = "deny" +fn_params_excessive_bools = "deny" +# --- Enforced: safety / correctness --- +or_fun_call = "deny" +rc_mutex = "deny" +disallowed_methods = "deny" +# --- Enforced: dev hygiene / no placeholders --- +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" +# --- Parked: documentation (ratchet) --- +doc_markdown = "allow" # ratchet (~640 sites; cargo clippy --fix-able) +missing_docs_in_private_items = "allow" # ratchet +missing_errors_doc = "allow" # ratchet +missing_panics_doc = "allow" # ratchet +missing_safety_doc = "allow" # ratchet +# --- Parked: style / idiom (ratchet; most are cargo clippy --fix-able) --- +uninlined_format_args = "allow" # ratchet (~420 sites) +redundant_closure_for_method_calls = "allow" # ratchet +manual_let_else = "allow" # ratchet +return_self_not_must_use = "allow" # ratchet +items_after_statements = "allow" # ratchet +map_unwrap_or = "allow" # ratchet +unnecessary_map_or = "allow" # ratchet +match_same_arms = "allow" # ratchet +explicit_iter_loop = "allow" # ratchet +unnested_or_patterns = "allow" # ratchet +needless_continue = "allow" # ratchet +assigning_clones = "allow" # ratchet +let_underscore_must_use = "allow" # ratchet +wildcard_imports = "allow" # ratchet +manual_string_new = "allow" # ratchet +manual_assert = "allow" # ratchet +manual_repeat_n = "allow" # ratchet +if_not_else = "allow" # ratchet +unused_self = "allow" # ratchet +unreadable_literal = "allow" # ratchet +iter_kv_map = "allow" # ratchet +collapsible_match = "allow" # ratchet +bool_comparison = "allow" # ratchet +clone_on_ref_ptr = "allow" # ratchet +field_reassign_with_default = "allow" # ratchet +derivable_impls = "allow" # ratchet +useless_vec = "allow" # ratchet +unwrap_or_default = "allow" # ratchet +empty_line_after_doc_comments = "allow" # ratchet +doc_lazy_continuation = "allow" # ratchet +doc_overindented_list_items = "allow" # ratchet +extra_unused_type_parameters = "allow" # ratchet +# --- Parked: complexity / size (ratchet) --- +too_many_arguments = "allow" # ratchet +too_many_lines = "allow" # ratchet +cognitive_complexity = "allow" # ratchet +type_complexity = "allow" # ratchet +struct_excessive_bools = "allow" # ratchet +rc_buffer = "allow" # ratchet +# --- Parked: restriction lints (aspirational; ratchet) --- +unwrap_used = "allow" # ratchet: pervasive in tests +expect_used = "allow" # ratchet: pervasive in tests +panic = "allow" # ratchet +indexing_slicing = "allow" # ratchet +allow_attributes = "allow" # ratchet: migrate #[allow] -> #[expect] +allow_attributes_without_reason = "allow" # ratchet +print_stdout = "allow" # examples/FFI demos print intentionally +print_stderr = "allow" # examples/FFI demos print intentionally + +[workspace.lints.rustdoc] +# Validated by `cargo doc` / `make doc` (RUSTDOCFLAGS="-D warnings"), not clippy. +broken_intra_doc_links = "deny" +private_intra_doc_links = "deny" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" +bare_urls = "allow" # ratchet +missing_crate_level_docs = "allow" # ratchet diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 211b0c09..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,115 +0,0 @@ -# ────────────────────────────────────────────────────────────── -# MANIFEST.in - source-distribution contents for cpex -# ────────────────────────────────────────────────────────────── - -# Core project files that SDists/Wheels should always carry -include LICENSE -include README.md -include pyproject.toml - -# Top-level config, examples and helper scripts -include *.example - -# Tooling/lint configuration dot-files (explicit so they're not lost) -include .env.example -include .interrogaterc -include .jshintrc -include whitesource.config -include .darglint -include .dockerignore -include .flake8 -include .htmlhintrc -include .pycodestyle -include .pylintrc* -include .whitesource -include .coveragerc -include .bumpversion.cfg -include .yamllint -include .editorconfig -include .snyk - -# Runtime data that lives *inside* the package at import time - -# Infrastructure configuration (nginx, monitoring) -recursive-include infra *.conf -recursive-include infra *.html -recursive-include infra *.json -recursive-include infra *.md -recursive-include infra *.sh -recursive-include infra *.yaml -recursive-include infra *.yml - -# Never publish caches, compiled or build outputs, deployment, agent_runtimes, etc. -global-exclude __pycache__ *.py[cod] *.so *.dylib -prune build -prune dist -prune .eggs -prune *.egg-info -prune charts -prune k8s -prune .devcontainer -exclude CLAUDE.* -exclude llms-full.txt -exclude AGENTS.md - -# Development and CI/CD artifacts -prune .github -prune .hypothesis -prune .benchmarks -prune .claude -prune todo -prune reports -prune logs - -# Testing caches -prune .pytest_cache -prune .ruff_cache -prune .mypy_cache -prune htmlcov - -# Virtual environments (including nested in plugins) -prune venv -prune .venv -prune env - -# Environment files (security sensitive!) -exclude .env - -# Database and runtime files -exclude *.db -exclude *.sqlite -exclude *.log - -# Lock files (including nested in plugins) -exclude uv.lock -global-exclude **/uv.lock - -# Coverage data -exclude .coverage -exclude .coverage.* - -# Additional development config files not needed in package -exclude .eslintrc.json -exclude eslint.config.js -exclude vitest.config.js -exclude .stylelintrc.json -exclude .markdownlint-cli2.yaml -exclude .spellcheck-en.txt -exclude .pyspelling.yml -exclude .prospector.yaml -exclude .hadolint.yaml -exclude .ruff.toml -exclude .pre-commit-config.yaml - -# Backup and temporary files -global-exclude *~ -global-exclude *.bak -global-exclude *.swp -global-exclude *.swo -global-exclude *.orig -global-exclude *.rej - -# OS files -global-exclude .DS_Store -global-exclude Thumbs.db -global-exclude desktop.ini diff --git a/Makefile b/Makefile index ab47d986..0fefe7e7 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,22 @@ -# Cpex Plugin Framework Makefile +# CPEX — Rust workspace Makefile # ============================================================================= +# Targets mirror CI (.github/workflows/) so a green `make ci` locally means a +# green pipeline. The CPEX Python package now lives on the `0.1.x` branch. SHELL := /bin/bash .SHELLFLAGS := -eu -o pipefail -c -# Project variables -PACKAGE_NAME = cpex -PROJECT_NAME = cpex -SRC_DIR = cpex -TEST_DIR = tests -TARGET ?= $(SRC_DIR) +CARGO ?= cargo +GO ?= go + +GO_DIR = go/cpex +GO_EXAMPLES_DIR = examples/go-demo -# Virtual-environment variables -VENV_DIR ?= $(HOME)/.venv/$(PROJECT_NAME) -VENV_BIN = $(VENV_DIR)/bin +HUGO ?= hugo +DOCS_DIR = docs +DOCS_PORT ?= 1313 -# Python -PYTHON = python3 -PYTEST_ARGS ?= +GOLANGCI_LINT ?= golangci-lint # ============================================================================= # Help @@ -25,660 +24,232 @@ PYTEST_ARGS ?= .PHONY: help help: - @echo "ContextForge Plugin Framework - Makefile" + @echo "CPEX (Rust) — Makefile" @echo "" - @echo "Environment Setup:" - @echo " venv Create a new virtual environment" - @echo " install Install package from sources" - @echo " install-dev Install package in editable mode with dev deps" - @echo " install-docs Install package in editable mode with docs deps" - @echo " install-all Install package in editable mode all optional deps" + @echo "Build:" + @echo " build Build the workspace (debug)" + @echo " build-release Build the workspace (release, size-optimized)" + @echo " check cargo check the workspace" + @echo " clean Remove the target/ directory" @echo "" - @echo "Development:" - @echo " lint Run all linters (black, ruff)" - @echo " lint-fix Auto-fix linting issues" - @echo " lint-check Check for linting issues without fixing" - @echo " format Format code with black and ruff" - @echo " type-check Run mypy type checking" + @echo "Lint & format:" + @echo " fmt Format Rust code (cargo fmt --all)" + @echo " lint CI lint gate: fmt --check + clippy -D warnings" + @echo " clippy Run clippy on the workspace (-D warnings)" + @echo " lint-fix Auto-fix: cargo fmt + clippy --fix" + @echo " machete Report unused dependencies (advisory)" @echo "" - @echo "Testing:" - @echo " test Run all tests with pytest" - @echo " test-cov Run tests with coverage report" - @echo " test-verbose Run tests in verbose mode" - @echo " test-file FILE=path/to/test.py Run specific test file" + @echo "Test:" + @echo " test Run all workspace tests" + @echo " test-ffi Run only the cpex-ffi crate tests" + @echo " test-all Rust tests + Go tests (with -race)" @echo "" - @echo "Documentation (requires Hugo: brew install hugo):" - @echo " docs Build the documentation site" - @echo " docs-serve Start local Hugo dev server with live reload" - @echo " docs-clean Remove generated documentation artifacts" - @echo "" - @echo "Building & Distribution:" - @echo " dist Build wheel + sdist into ./dist" - @echo " wheel Build wheel only" - @echo " sdist Build source distribution only" - @echo " verify Build and verify package with twine" + @echo "Supply chain & coverage:" + @echo " audit cargo deny check (advisories, licenses, bans, sources)" + @echo " coverage Line/region coverage summary (cargo-llvm-cov; report only)" @echo "" - @echo "Rust (cpex-core / cpex-ffi / cpex-sdk):" - @echo " rust-build Build the Rust workspace (debug)" - @echo " rust-build-release Build the Rust workspace (release)" - @echo " rust-test Run all Rust workspace tests" - @echo " rust-test-ffi Run only the cpex-ffi crate tests" - @echo " rust-fmt Format Rust code with rustfmt" - @echo " rust-clippy Run clippy on the Rust workspace" - @echo " rust-lint Auto-fix style + clippy issues (alias for rust-lint-fix)" - @echo " rust-lint-fix Same as rust-lint — mutating fmt + clippy --fix" - @echo " rust-lint-check Read-only fmt --check + clippy (CI-safe)" - @echo " rust-clean Remove the Rust target/ directory" + @echo "Docs:" + @echo " doc Build API docs (rustdoc, -D warnings)" + @echo " docs Build the Hugo documentation site" + @echo " docs-serve Hugo dev server with live reload" + @echo " docs-clean Remove generated documentation artifacts" @echo "" - @echo "Go (go/cpex):" - @echo " go-build Build the Go cpex package (requires libcpex_ffi)" - @echo " go-test Run Go tests" - @echo " go-test-race Run Go tests with the race detector" - @echo " go-fmt Format Go code with gofmt" - @echo " go-vet Run go vet" - @echo " go-lint Auto-fix style + lint issues (alias for go-lint-fix)" - @echo " go-lint-fix Same as go-lint — gofmt -w + vet + golangci-lint --fix" - @echo " go-lint-check Read-only gofmt -l + vet + golangci-lint (CI-safe)" + @echo "Go bindings (go/cpex):" + @echo " go-build go-test go-test-race go-fmt go-vet go-lint-check go-lint-fix" @echo "" @echo "Examples:" @echo " examples-build Build all Rust + Go examples (catches stale APIs)" @echo " examples-run Run all examples end-to-end" @echo "" @echo "End-to-end:" - @echo " test-all Run Rust workspace tests + Go tests w/ -race" - @echo " ci Lint-check + tests + examples-build (CI gate)" - @echo "" - @echo "Utilities:" - @echo " clean Remove all artifacts and builds" - @echo " clean-all Remove artifacts, builds, and venv" - @echo " run-main Run main.py with PYTHONPATH set" - @echo " uninstall Uninstall package" - @echo " grpc-proto Generate gRPC stubs for external plugin transport" + @echo " ci Lint + tests + examples-build (CI gate)" # ============================================================================= -# Virtual Environment +# Build # ============================================================================= -.PHONY: venv -venv: - @echo "🔧 Creating virtual environment..." - @rm -rf "$(VENV_DIR)" - @test -d "$(VENV_DIR)" || mkdir -p "$(VENV_DIR)" - @$(PYTHON) -m venv "$(VENV_DIR)" - @$(VENV_BIN)/python -m pip install --upgrade pip setuptools wheel - @echo "✅ Virtual env created at: $(VENV_DIR)" - @echo "💡 Activate it with:" - @echo " source $(VENV_DIR)/bin/activate" - -.PHONY: install -install: venv - @echo "📦 Installing package..." - @$(VENV_BIN)/pip install . - @echo "✅ Package installed" - -.PHONY: install-dev -install-dev: venv - @echo "📦 Installing package with dev dependencies..." - @$(VENV_BIN)/pip install -e ".[dev,all]" - @echo "✅ Package installed in editable mode with dev dependencies" - -.PHONY: install-docs -install-docs: venv - @echo "📦 Installing package with docs dependencies..." - @$(VENV_BIN)/pip install -e ".[docs]" - @echo "✅ Package installed in editable mode with docs dependencies" - -.PHONY: install-all -install-all: venv - @echo "📦 Installing package with all optional dependencies..." - @$(VENV_BIN)/pip install -e ".[dev,docs,all]" - @echo "✅ Package installed in editable mode with all optional dependencies" - -.PHONY: uninstall -uninstall: - @echo "🗑️ Uninstalling package..." - @$(VENV_BIN)/pip uninstall -y $(PACKAGE_NAME) 2>/dev/null || true - @echo "✅ Package uninstalled" +.PHONY: build +build: + @$(CARGO) build --workspace + +.PHONY: build-release +build-release: + @$(CARGO) build --release --workspace + +.PHONY: check +check: + @$(CARGO) check --workspace + +.PHONY: clean +clean: + @$(CARGO) clean # ============================================================================= -# Linting & Formatting +# Lint & format # ============================================================================= -.PHONY: vulture -vulture: - @echo "⚡ Running vulture on $(TARGET)..." - @$(VENV_BIN)/vulture $(TARGET) - -.PHONY: interrogate -interrogate: - @echo "⚡ Running interrogate on $(TARGET)..." - @$(VENV_BIN)/interrogate $(TARGET) - -.PHONY: interrogate-verbose -interrogate-verbose: - @echo "⚡ Running interrogate on $(TARGET)..." - @$(VENV_BIN)/interrogate -vv $(TARGET) - -.PHONY: radon -radon: - @echo "⚡ Running radon on $(TARGET)..." - @$(VENV_BIN)/radon cc $(TARGET) --min C --show-complexity - -.PHONY: ruff -ruff: - @echo "⚡ Running ruff on $(TARGET)..." - @$(VENV_BIN)/ruff check $(TARGET) --fix - @$(VENV_BIN)/ruff format $(TARGET) - -.PHONY: ruff-check -ruff-check: - @echo "⚡ Checking ruff on $(TARGET)..." - @$(VENV_BIN)/ruff check $(TARGET) - -.PHONY: ruff-fix -ruff-fix: - @echo "⚡ Fixing ruff issues in $(TARGET)..." - @$(VENV_BIN)/ruff check --fix $(TARGET) - -.PHONY: ruff-format -ruff-format: - @echo "⚡ Formatting with ruff on $(TARGET)..." - @$(VENV_BIN)/ruff format $(TARGET) - -.PHONY: ruff-format-check -ruff-format-check: - @echo "⚡ Checking formatting with ruff on $(TARGET)..." - @$(VENV_BIN)/ruff format --check $(TARGET) - -.PHONY: format -format: ruff-format - @echo "✅ Code formatted" +.PHONY: fmt +fmt: + @$(CARGO) fmt --all + +.PHONY: clippy +clippy: + @$(CARGO) clippy --workspace --all-targets -- -D warnings +# CI-safe gate: read-only fmt check + clippy. Lint levels come from the +# [workspace.lints] wall in Cargo.toml. .PHONY: lint -lint: lint-fix +lint: + @echo "🦀 fmt --check + clippy -D warnings ..." + @$(CARGO) fmt --all -- --check + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "✅ lint passed" +# Developer convenience: format, then apply clippy's machine-applicable fixes. .PHONY: lint-fix lint-fix: - @# Handle file arguments - @target_file="$(word 2,$(MAKECMDGOALS))"; \ - if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ - actual_target="$$target_file"; \ - else \ - actual_target="$(TARGET)"; \ - fi; \ - for target in $$(echo $$actual_target); do \ - if [ ! -e "$$target" ]; then \ - echo "❌ File/directory not found: $$target"; \ - exit 1; \ - fi; \ - done; \ - echo "🔧 Fixing lint issues in $$actual_target..."; \ - $(MAKE) --no-print-directory ruff-fix TARGET="$$actual_target"; \ - $(MAKE) --no-print-directory ruff-format TARGET="$$actual_target"; \ - echo "✅ Lint issues fixed" - -.PHONY: lint-check -lint-check: - @# Handle file arguments - @target_file="$(word 2,$(MAKECMDGOALS))"; \ - if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ - actual_target="$$target_file"; \ - else \ - actual_target="$(TARGET)"; \ - fi; \ - echo "🔍 Checking for lint issues..."; \ - $(MAKE) --no-print-directory ruff-check TARGET="$$actual_target"; \ - $(MAKE) --no-print-directory ruff-format-check TARGET="$$actual_target"; \ - echo "✅ Lint check complete" - -.PHONY: type-check -type-check: - @echo "🔍 Running mypy type checking..." - @$(VENV_BIN)/mypy $(SRC_DIR) --ignore-missing-imports - @echo "✅ Type checking complete" + @$(CARGO) fmt --all + @$(CARGO) clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings + +# Advisory: cargo-machete static analysis false-positives on macro/derive-only +# crates, so this is not part of the blocking `lint` gate. +.PHONY: machete +machete: + @command -v cargo-machete >/dev/null 2>&1 || $(CARGO) install cargo-machete --locked + @cargo machete || true # ============================================================================= -# Testing +# Test # ============================================================================= .PHONY: test test: - @echo "🧪 Running tests..." - @PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest -n auto $(TEST_DIR) $(PYTEST_ARGS) - -.PHONY: test-cov -test-cov: - @echo "🧪 Running tests with coverage..." - @PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest -n auto $(TEST_DIR) \ - --cov=$(SRC_DIR) \ - --cov-report=html \ - --cov-report=term-missing \ - $(PYTEST_ARGS) - @echo "📊 Coverage report generated in htmlcov/" - -.PHONY: test-verbose -test-verbose: - @$(MAKE) test PYTEST_ARGS="-vv" - -.PHONY: test-file -test-file: - @if [ -z "$(FILE)" ]; then \ - echo "❌ Please specify FILE=path/to/test.py"; \ - exit 1; \ - fi - @echo "🧪 Running test file: $(FILE)..." - @PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest $(FILE) $(PYTEST_ARGS) - -doctest: - @echo "🧪 Running doctest on all modules..." - @PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest --doctest-modules cpex/ --ignore=cpex/templates --tb=short --no-cov --disable-warnings + @$(CARGO) test --workspace + +.PHONY: test-ffi +test-ffi: + @$(CARGO) test -p cpex-ffi --lib + +# Rust workspace tests + Go tests under the race detector. +.PHONY: test-all +test-all: test go-test-race # ============================================================================= -# Documentation (Hugo Book theme — no Python deps required) +# Supply chain & coverage # ============================================================================= -HUGO ?= hugo -DOCS_DIR = docs -DOCS_PORT ?= 1313 +# Single supply-chain gate (advisories + licenses + bans + sources). Policy +# lives in deny.toml. +.PHONY: audit +audit: + @command -v cargo-deny >/dev/null 2>&1 || $(CARGO) install cargo-deny --locked + @cargo deny check + +# Report-only: prints a coverage summary, does NOT enforce a threshold. +# Add `--fail-under-lines N` here and in coverage.yaml to turn on a gate. +.PHONY: coverage +coverage: + @command -v cargo-llvm-cov >/dev/null 2>&1 || $(CARGO) install cargo-llvm-cov --locked + @cargo llvm-cov --workspace --summary-only + +# ============================================================================= +# Docs +# ============================================================================= + +.PHONY: doc +doc: + @RUSTDOCFLAGS="-D warnings" $(CARGO) doc --workspace --no-deps .PHONY: docs docs: @command -v $(HUGO) >/dev/null 2>&1 || { echo "❌ Hugo not found. Install with: brew install hugo"; exit 1; } - @echo "📖 Building documentation site..." @cd $(DOCS_DIR) && $(HUGO) - @echo "✅ Site built in $(DOCS_DIR)/public/" .PHONY: docs-serve docs-serve: @command -v $(HUGO) >/dev/null 2>&1 || { echo "❌ Hugo not found. Install with: brew install hugo"; exit 1; } - @echo "📖 Starting Hugo dev server on http://localhost:$(DOCS_PORT)/ ..." @cd $(DOCS_DIR) && $(HUGO) server --buildDrafts --port $(DOCS_PORT) .PHONY: docs-clean docs-clean: - @echo "🧹 Cleaning documentation build artifacts..." @rm -rf $(DOCS_DIR)/public $(DOCS_DIR)/resources - @echo "✅ Documentation artifacts cleaned" - -# ============================================================================= -# Building & Distribution -# ============================================================================= - -.PHONY: check-manifest -check-manifest: - @echo "📦 Verifying MANIFEST.in completeness..." - @$(VENV_BIN)/check-manifest - -.PHONY: dist -dist: clean - @echo "📦 Building distribution packages..." - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @$(VENV_BIN)/python -m pip install --quiet --upgrade pip build - @$(VENV_BIN)/python -m build - @echo "✅ Wheel & sdist written to ./dist" - -.PHONY: wheel -wheel: - @echo "📦 Building wheel..." - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @$(VENV_BIN)/python -m pip install --quiet --upgrade pip build - @$(VENV_BIN)/python -m build -w - @echo "✅ Wheel written to ./dist" - -.PHONY: sdist -sdist: - @echo "📦 Building source distribution..." - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @$(VENV_BIN)/python -m pip install --quiet --upgrade pip build - @$(VENV_BIN)/python -m build -s - @echo "✅ Source distribution written to ./dist" - -.PHONY: verify -verify: dist check-manifest - @echo "🔍 Verifying package..." - @$(VENV_BIN)/twine check dist/* - @echo "✅ Package verified - ready to publish" - -.PHONY: publish-test -publish-test: verify - @echo "📤 Publishing to TestPyPI..." - @$(VENV_BIN)/twine upload --repository testpypi dist/* - -.PHONY: publish -publish: verify - @echo "📤 Publishing to PyPI..." - @$(VENV_BIN)/twine upload dist/* - -# ============================================================================= -# Utilities -# ============================================================================= - -.PHONY: run-main -run-main: - @echo "🚀 Running main.py..." - @PYTHONPATH="$(SRC_DIR)" $(PYTHON) main.py - -.PHONY: clean -clean: - @echo "🧹 Cleaning build artifacts..." - @find . -type f -name '*.py[co]' -delete - @find . -type d -name __pycache__ -delete - @rm -rf *.egg-info .pytest_cache tests/.pytest_cache build dist .ruff_cache .coverage htmlcov .mypy_cache docs/public docs/resources - @echo "✅ Build artifacts cleaned" - -.PHONY: clean-all -clean-all: clean - @echo "🧹 Cleaning virtual environment..." - @rm -rf "$(VENV_DIR)" - @echo "✅ Everything cleaned" - -.PHONY: show-venv -show-venv: - @echo "Virtual environment: $(VENV_DIR)" - @if [ -d "$(VENV_DIR)" ]; then \ - echo "Status: ✅ EXISTS"; \ - echo "Python: $$($(VENV_BIN)/python --version 2>&1)"; \ - echo "Pip: $$($(VENV_BIN)/pip --version 2>&1)"; \ - else \ - echo "Status: ❌ NOT FOUND"; \ - echo "Run 'make venv' to create it"; \ - fi - -.PHONY: show-deps -show-deps: - @echo "📋 Installed packages:" - @$(VENV_BIN)/pip list - - -.PHONY: grpc-proto -grpc-proto: ## Generate gRPC stubs for external plugin transport - @echo "🔧 Generating gRPC protocol buffer stubs..." - @test -d "$(VENV_DIR)" || $(MAKE) venv - @/bin/bash -c "source $(VENV_DIR)/bin/activate && \ - uv pip show grpcio-tools >/dev/null 2>&1 || \ - uv pip install -q grpcio-tools" - @/bin/bash -c "source $(VENV_DIR)/bin/activate && \ - python -m grpc_tools.protoc \ - -I cpex/framework/external/grpc/proto \ - --python_out=cpex/framework/external/grpc/proto \ - --pyi_out=cpex/framework/external/grpc/proto \ - --grpc_python_out=cpex/framework/external/grpc/proto \ - cpex/framework/external/grpc/proto/plugin_service.proto" - @echo "🔧 Fixing imports in generated files..." - @if [ "$$(uname)" = "Darwin" ]; then \ - sed -i '' 's/^import plugin_service_pb2/from cpex.framework.external.grpc.proto import plugin_service_pb2/' \ - cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py; \ - else \ - sed -i 's/^import plugin_service_pb2/from cpex.framework.external.grpc.proto import plugin_service_pb2/' \ - cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py; \ - fi - @echo "🔧 Adding noqa comments to generated files..." - @if [ "$$(uname)" = "Darwin" ]; then \ - sed -i '' '1s/^/# noqa: D100, D101, D102, D103, D104, D107, D400, D415\n# ruff: noqa\n# type: ignore\n# pylint: skip-file\n# Generated by protoc - do not edit\n/' \ - cpex/framework/external/grpc/proto/plugin_service_pb2.py \ - cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py \ - cpex/framework/external/grpc/proto/plugin_service_pb2.pyi; \ - else \ - sed -i '1s/^/# noqa: D100, D101, D102, D103, D104, D107, D400, D415\n# ruff: noqa\n# type: ignore\n# pylint: skip-file\n# Generated by protoc - do not edit\n/' \ - cpex/framework/external/grpc/proto/plugin_service_pb2.py \ - cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py \ - cpexs/framework/external/grpc/proto/plugin_service_pb2.pyi; \ - fi - @echo "✅ gRPC stubs generated in cpex/framework/external/grpc/proto/" - -.PHONY: env-example -env-example: - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @pip install settings-doc - @settings-doc generate --class cpex.framework.settings.PluginsSettings --output-format dotenv > .env.template - -# ============================================================================= -# Rust workspace (cpex-core, cpex-ffi, cpex-sdk) -# ============================================================================= - -CARGO ?= cargo -GO ?= go -GO_DIR = go/cpex - -.PHONY: rust-build -rust-build: - @echo "🦀 Building Rust workspace (debug)..." - @$(CARGO) build --workspace - @echo "✅ Rust workspace built" - -.PHONY: rust-build-release -rust-build-release: - @echo "🦀 Building Rust workspace (release)..." - @$(CARGO) build --release --workspace - @echo "✅ Rust workspace built (release)" - -.PHONY: rust-test -rust-test: - @echo "🧪 Running Rust workspace tests..." - @$(CARGO) test --workspace - @echo "✅ Rust tests passed" - -.PHONY: rust-test-ffi -rust-test-ffi: - @echo "🧪 Running cpex-ffi tests..." - @$(CARGO) test -p cpex-ffi --lib - @echo "✅ cpex-ffi tests passed" - -.PHONY: rust-fmt -rust-fmt: - @echo "🦀 Formatting Rust code..." - @$(CARGO) fmt --all - @echo "✅ Rust code formatted" - -.PHONY: rust-clippy -rust-clippy: - @echo "🦀 Running clippy..." - @$(CARGO) clippy --workspace --all-targets -- -D warnings - @echo "✅ Clippy clean" - -# rust-lint is a developer convenience: format the code, then apply -# clippy's auto-fixes. --allow-dirty/--allow-staged let clippy run on -# in-progress edits rather than refusing on a non-clean tree. -.PHONY: rust-lint -rust-lint: rust-lint-fix - -.PHONY: rust-lint-fix -rust-lint-fix: - @echo "🦀 Formatting + auto-fixing Rust..." - @$(CARGO) fmt --all - @$(CARGO) clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings - @echo "✅ Rust lint-fix complete" - -# rust-lint-check is the CI-safe variant: no writes. Fails if formatting -# drifted (fmt --check) or clippy has any warning. -.PHONY: rust-lint-check -rust-lint-check: - @echo "🦀 Checking Rust formatting + clippy (read-only)..." - @$(CARGO) fmt --all -- --check - @$(CARGO) clippy --workspace --all-targets -- -D warnings - @echo "✅ Rust lint-check passed" - -.PHONY: rust-clean -rust-clean: - @echo "🧹 Removing Rust target directory..." - @$(CARGO) clean - @echo "✅ target/ removed" # ============================================================================= # Go bindings (go/cpex) # ============================================================================= # -# go/cpex links against the cpex-ffi cdylib at target/release. Targets -# below that touch Go ensure the release build is current first — Go's -# linker errors on missing libcpex_ffi.dylib are easy to misread. +# 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. .PHONY: go-build -go-build: rust-build-release - @echo "🐹 Building Go cpex package..." +go-build: build-release @cd $(GO_DIR) && $(GO) build ./... - @echo "✅ Go package built" .PHONY: go-test -go-test: rust-build-release - @echo "🧪 Running Go tests..." +go-test: build-release @cd $(GO_DIR) && $(GO) test -count=1 ./... - @echo "✅ Go tests passed" .PHONY: go-test-race -go-test-race: rust-build-release - @echo "🧪 Running Go tests with race detector..." +go-test-race: build-release @cd $(GO_DIR) && $(GO) test -count=1 -race ./... - @echo "✅ Go tests passed (with -race)" .PHONY: go-vet -go-vet: rust-build-release - @echo "🐹 Running go vet..." +go-vet: build-release @cd $(GO_DIR) && $(GO) vet ./... - @echo "✅ go vet clean" -# go-fmt rewrites .go files in place via gofmt. Read-only counterpart -# is `gofmt -l`, used inside go-lint-check. .PHONY: go-fmt go-fmt: - @echo "🐹 Formatting Go code..." @cd $(GO_DIR) && $(GO) fmt ./... - @echo "✅ Go code formatted" - -# go-lint is a developer convenience: format, vet, then run -# golangci-lint with --fix. We require golangci-lint to be installed — -# print an install hint rather than silently skipping it (skipping -# would let style drift land unnoticed). -GOLANGCI_LINT ?= golangci-lint - -.PHONY: go-lint -go-lint: go-lint-fix .PHONY: go-lint-fix -go-lint-fix: rust-build-release +go-lint-fix: build-release @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ - echo "❌ golangci-lint not found. Install:"; \ - echo " brew install golangci-lint"; \ - echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ - exit 1; \ - } - @echo "🐹 Formatting + auto-fixing Go..." - @cd $(GO_DIR) && $(GO) fmt ./... - @cd $(GO_DIR) && $(GO) vet ./... - @cd $(GO_DIR) && $(GOLANGCI_LINT) run --fix ./... - @echo "✅ Go lint-fix complete" + echo "❌ golangci-lint not found (brew install golangci-lint)"; exit 1; } + @cd $(GO_DIR) && $(GO) fmt ./... && $(GO) vet ./... && $(GOLANGCI_LINT) run --fix ./... -# go-lint-check is the CI-safe variant: read-only. `gofmt -l` lists -# files that would be reformatted and we fail if that list is non-empty. .PHONY: go-lint-check -go-lint-check: rust-build-release +go-lint-check: build-release @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ - echo "❌ golangci-lint not found. Install:"; \ - echo " brew install golangci-lint"; \ - echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ - exit 1; \ - } - @echo "🐹 Checking Go formatting + vet + golangci-lint (read-only)..." + echo "❌ golangci-lint not found (brew install golangci-lint)"; exit 1; } @cd $(GO_DIR) && unformatted=$$(gofmt -l .); \ - if [ -n "$$unformatted" ]; then \ - echo "❌ Files need formatting:"; echo "$$unformatted"; \ - exit 1; \ - fi - @cd $(GO_DIR) && $(GO) vet ./... - @cd $(GO_DIR) && $(GOLANGCI_LINT) run ./... - @echo "✅ Go lint-check passed" + if [ -n "$$unformatted" ]; then echo "❌ Files need formatting:"; echo "$$unformatted"; exit 1; fi + @cd $(GO_DIR) && $(GO) vet ./... && $(GOLANGCI_LINT) run ./... # ============================================================================= # Examples # ============================================================================= # -# Building examples is the cheapest way to catch stale public-API usage: -# cargo test / go test only build code reachable from tests, so an -# example file using a renamed function compiles fine in isolation but -# breaks at example-build time. Wire this into CI. - -GO_EXAMPLES_DIR = examples/go-demo +# Building examples is the cheapest way to catch stale public-API usage: cargo +# test / go test only build code reachable from tests, so an example using a +# renamed function compiles fine in isolation but breaks at example-build time. .PHONY: rust-examples-build rust-examples-build: - @echo "🦀 Building Rust examples..." @$(CARGO) build --examples --workspace - @echo "✅ Rust examples built" .PHONY: go-examples-build -go-examples-build: rust-build-release - @echo "🐹 Building Go examples..." +go-examples-build: build-release @cd $(GO_EXAMPLES_DIR) && $(GO) build ./... - @echo "✅ Go examples built" .PHONY: examples-build examples-build: rust-examples-build go-examples-build @echo "✅ All examples built" -# Running examples — useful for manual smoke-testing. Output goes to -# stdout and may be noisy. Each example is self-contained: prints -# scenario output and exits 0 on success. .PHONY: examples-run examples-run: examples-build - @echo "🏃 Running cpex-core plugin_demo..." @$(CARGO) run --example plugin_demo -p cpex-core --quiet >/dev/null - @echo "✅ plugin_demo OK" - @echo "🏃 Running cpex-core cmf_capabilities_demo..." @$(CARGO) run --example cmf_capabilities_demo -p cpex-core --quiet >/dev/null - @echo "✅ cmf_capabilities_demo OK" - @echo "🏃 Running go-demo (generic payload)..." @cd $(GO_EXAMPLES_DIR) && $(GO) run . >/dev/null - @echo "✅ go-demo OK" - @echo "🏃 Running go-demo cmf-demo..." @cd $(GO_EXAMPLES_DIR) && $(GO) run ./cmd/cmf-demo >/dev/null - @echo "✅ cmf-demo OK" @echo "✅ All examples ran successfully" # ============================================================================= -# End-to-end +# CI gate # ============================================================================= - -# test-all bundles the Rust workspace tests and the Go tests under -# the race detector. Skips the Python pytest suite — use -# `make test rust-test go-test-race` if you want all three. -.PHONY: test-all -test-all: rust-test go-test-race - @echo "✅ Rust + Go test suites passed" - -# ci is the canonical CI gate: read-only lint checks, full test -# suites, and example builds. If this passes locally, the same checks -# will pass in CI. +# +# Canonical local gate: read-only lint, full test suite, example builds. If +# this passes locally, the same checks pass in CI. .PHONY: ci -ci: rust-lint-check test-all examples-build +ci: lint test examples-build @echo "✅ CI gate passed (lint + tests + examples)" - -# ============================================================================= -# Development shortcuts -# ============================================================================= - -.PHONY: dev-setup -dev-setup: install-dev - @echo "✅ Development environment ready!" - @echo "" - @echo "Next steps:" - @echo " 1. Activate venv: source $(VENV_DIR)/bin/activate" - @echo " 2. Run tests: make test" - @echo " 3. Run main: make run-main" - -.PHONY: quick-test -quick-test: - @echo "🚀 Quick test (no coverage)..." - @PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest $(TEST_DIR) -v --tb=short - -.PHONY: watch-test -watch-test: - @echo "👀 Watching for changes..." - @while true; do \ - $(MAKE) quick-test; \ - echo ""; \ - echo "Waiting for changes... (Ctrl+C to stop)"; \ - sleep 2; \ - done - -# Prevent make from treating additional arguments as targets -%: - @: diff --git a/README.md b/README.md index 2725ad15..1ce762f1 100644 --- a/README.md +++ b/README.md @@ -1,364 +1,168 @@

- ContextForge Plugin Extensibility Framework (CPEX) logo + CPEX logo
-# CPEX — ContextForge Plugin Extensibility Framework +# CPEX -A composable enforcement framework for AI agents and toolchains. +A policy and authorization framework for agentic applications. [![CI](https://github.com/contextforge-org/cpex/actions/workflows/ci.yml/badge.svg)](https://github.com/contextforge-org/cpex/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) -[![PyPI](https://img.shields.io/pypi/v/cpex.svg?color=blue)](https://pypi.org/project/cpex) +[![crates.io](https://img.shields.io/crates/v/cpex.svg)](https://crates.io/crates/cpex) +[![docs.rs](https://img.shields.io/docsrs/cpex)](https://docs.rs/cpex) +[![MSRV](https://img.shields.io/badge/MSRV-1.96-blue.svg)](rust-toolchain.toml) -> [**Read the project vision**](https://contextforge-org.github.io/cpex/docs/vision/) to learn why hooks, plugins, and policy are the path to agent security. +> [!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. The reposition is a re-architecture, not an abandonment. Python bindings (PyO3) over the Rust core are coming. ## What's CPEX? -CPEX lets you intercept, enforce, and extend application behavior through plugins without modifying core logic. +CPEX is a deterministic reference monitor between an untrusted agent and the capabilities it invokes. -Define hook points in your application, write plugins that attach to them, and compose enforcement pipelines that run automatically. +AI agents can be steered by injected content, confused by tool output, or simply make mistakes. CPEX mediates every operation an agent triggers (tool calls, A2A methods, inference calls, prompt and resource fetches) against state the agent cannot see or forge: identity, delegation chains, taint labels, and an append-only audit log. -```python -from cpex.framework import hook, Plugin, PluginResult - -class RateLimitPlugin(Plugin): - @hook("tool_pre_invoke") - async def check_rate_limit(self, payload, context): - if self.is_over_limit(context): - return PluginResult( - continue_processing=False, - violation=PluginViolation(reason="Rate limit exceeded", code="RATE_LIMIT") - ) - return PluginResult(continue_processing=True) -``` - -Register the plugin, and it runs at every hook invocation. No changes to your application logic. - -## Install - -```bash -pip install cpex -``` - -## Why CPEX? - -AI agents execute across trust domains, calling tools, accessing data, and delegating to other agents. Adding security, governance, or policy enforcement typically means embedding that logic directly into application code, leading to duplication, tight coupling, and drift. - -CPEX introduces **standardized interception hooks** between your application and its operations. Plugins attach to these hooks and run automatically, keeping enforcement logic separate from business logic. - -**What you can build with CPEX:** - -- **Security** — access control, prompt injection detection, data loss prevention -- **Observability** — request tracing, audit logging, metrics collection -- **Governance** — policy enforcement, compliance validation, approval workflows -- **Reliability** — rate limiting, circuit breakers, response validation - -CPEX is designed for modern **AI and agent systems**, but works equally well for any application that needs **safe, modular extensibility**. - -## How It Works - -Your application defines **hooks** — named interception points before and after critical operations. Plugins register against these hooks and execute automatically when triggered. - -``` -Application → Hook Point → Plugin Manager → Application (remaining processing) → Result - │ - ┌──────┼──────┐ - ▼ ▼ ▼ - Plugin Plugin Plugin -``` - -The plugin manager handles registration, ordering, execution, timeouts, and error isolation. You get a deterministic pipeline with no surprises. - -## Core Concepts - -### Hooks - -A hook is a named interception point in your application. You define a hook where you want plugins to be able to run, then call it there. - -**Define hook models:** - -```python -from cpex.framework import PluginPayload, PluginResult - -class EmailPayload(PluginPayload): - recipient: str - subject: str - body: str - -EmailResult = PluginResult[EmailPayload] -``` - -**Register it:** - -```python -from cpex.framework.hooks.registry import get_hook_registry - -registry = get_hook_registry() -registry.register_hook("email_pre_send", EmailPayload, EmailResult) -``` - -**Call the hook in your application:** +
+ CPEX mediates every operation an untrusted LLM triggers, evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge +
-```python -async def send_email(recipient: str, subject: str, body: str): - payload = EmailPayload(recipient=recipient, subject=subject, body=body) - context = GlobalContext(request_id="req-123") +You write policy as declarative, attribute-based rules with explicit effects. CPEX evaluates that policy at the boundary and enforces the result, allowing, denying, redacting, delegating, or tainting before the operation proceeds. - result, _ = await manager.invoke_hook("email_pre_send", payload, context) +## Same request, different data - if not result.continue_processing: - raise PolicyError(result.violation.reason) +Three callers issue the identical `get_compensation` request. The backend returns the same record. What each receives differs, because policy decides per identity. - # proceed with sending - await smtp.send(payload.recipient, payload.subject, payload.body) +```yaml +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + result: + ssn: "str | redact(!perm.view_ssn)" ``` -CPEX also ships with built-in hooks for common AI operations (`tool_pre_invoke`, `tool_post_invoke`, `prompt_pre_fetch`, `prompt_post_fetch`, `resource_pre_fetch`, `resource_post_fetch`, `agent_pre_invoke`, `agent_post_invoke`). These follow the same pattern and are ready to use without registration. - -### Plugins - -A plugin is a class that implements one or more hook handlers. Use the `@hook` decorator to attach a method to any hook by name: +- An HR analyst with `view_ssn` gets the full record. +- An HR analyst without `view_ssn` gets the record with the SSN redacted before it leaves CPEX. The backend never sees the difference. +- An engineer is denied at `require(role.hr)`. The call never reaches the backend. -```python -from cpex.framework import hook, Plugin, PluginViolation, PluginResult +No application code changed between the three outcomes. The policy did. -class EmailFilterPlugin(Plugin): - @hook("email_pre_send") - async def block_external_domains(self, payload: EmailPayload, context) -> PluginResult: - allowed = self.config.config.get("allowed_domains", []) - domain = payload.recipient.split("@")[-1] +## What you can express - if allowed and domain not in allowed: - return PluginResult( - continue_processing=False, - violation=PluginViolation( - reason="Domain not allowed", - code="DOMAIN_BLOCKED", - details={"domain": domain} - ) - ) +CPEX composes the controls an agent stack needs, evaluated against identity claims, relationships, roles, and attributes (ReBAC, RBAC, ABAC). A few sketches: - return PluginResult(continue_processing=True) -``` - -The `@hook` decorator decouples method names from hook names, which is useful when a plugin handles multiple hooks or when names would otherwise conflict. - -For built-in hooks, you can also use the naming convention directly (method name matches hook name) without a decorator: +**Authorization** on both request inputs and response outputs, for tools, resources, prompts, A2A methods, and other agent interfaces: -```python -class ContentFilterPlugin(Plugin): - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - blocked = self.config.config.get("blocked_tools", []) - if payload.name in blocked: - return ToolPreInvokeResult( - continue_processing=False, - violation=PluginViolation(reason="Tool blocked by policy", code="TOOL_BLOCKED") - ) - return ToolPreInvokeResult(continue_processing=True) +```yaml +policy: + - "require(role.hr | role.security)" +args: + region: "enum(us, eu, apac)" # validate inputs +result: + salary: "int | redact(!perm.view_comp)" # redact outputs by permission ``` -A plugin method can: - -- **Allow** execution to continue -- **Block** execution with a violation -- **Modify** the payload (using copy-on-write isolation) +**PDP composition**: gate with your preferred policy engine (CEL and Cedar ship as builtins; OPA, AuthZEN, and NeMo are recognized dialects you wire to a host resolver): -### Execution Modes - -Plugins run in phases in this order: - -``` -sequential → transform → audit → concurrent → fire_and_forget +```yaml +policy: + - cel: + expr: "subject.department == 'compliance' || 'admin' in subject.roles" + on_deny: + - "deny('not permitted by policy', 'pdp_denied')" ``` -| Mode | Execution | Can block? | Can modify? | State merged? | Use case | -|------|-----------|:-----------:|:-----------:|:-------------:|---------| -| `sequential` | Serial, chained | Yes | Yes | Yes | Policy enforcement + transformation | -| `transform` | Serial, chained | No | Yes | Yes | Data transformation (redaction, rewriting) | -| `audit` | Serial | No | No | No | Logging, monitoring, metrics | -| `concurrent` | Parallel, fail-fast | Yes | No | Yes | Independent policy gates | -| `fire_and_forget` | Background, after all phases | No | No | No | Telemetry, audit logs | -| `disabled` | Not loaded | — | — | — | Plugin off | - -- **`sequential`** plugins are awaited one at a time in priority order. Each receives the chained output of the previous plugin. Can halt the pipeline and modify payloads. Use for enforcement + transformation. -- **`transform`** plugins are awaited one at a time after all sequential plugins. Can modify payloads but blocking attempts are suppressed. Use for data transformation pipelines (PII redaction, prompt rewriting) that should not have policy-enforcement power. -- **`audit`** plugins are awaited one at a time after transform. Observe-only: payload modifications are discarded and violations are logged but do not block. Use for monitoring, auditing, and gradual rollout of policies. -- **`concurrent`** plugins are dispatched in parallel after audit. Can halt the pipeline (fail-fast on first blocking result) but payload modifications are discarded to avoid non-deterministic last-writer-wins races. Use for independent policy gates. -- **`fire_and_forget`** plugins are dispatched as background tasks after all other phases. They receive an isolated snapshot. Cannot block or modify. Use for telemetry and async side effects. +**Delegation** as an explicit effect: RFC 8693 token exchange that scopes and reduces privilege before downstream calls, verified after the exchange: -Error handling is configured separately with `on_error`, independent of mode: - -| `on_error` | Behavior | -|-----------|---------| -| `fail` | Pipeline halts, error propagates (default) | -| `ignore` | Error logged; pipeline continues | -| `disable` | Error logged; plugin auto-disabled; pipeline continues | - -### Plugin Manager - -The `PluginManager` orchestrates everything: - -```python -from cpex.framework import PluginManager, GlobalContext -from cpex.framework.hooks.tools import ToolPreInvokePayload - -manager = PluginManager("plugins/config.yaml") -await manager.initialize() - -context = GlobalContext(request_id="req-123", user="alice") -payload = ToolPreInvokePayload(name="web_search", args={"query": "CPEX framework"}) - -result, plugin_contexts = await manager.invoke_hook("tool_pre_invoke", payload, context) - -if result.continue_processing: - # Proceed — use result.modified_payload if a plugin transformed it - pass -else: - # A plugin blocked execution - print(f"Blocked: {result.violation.reason}") +```yaml +policy: + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "delegation.granted.permissions contains 'read_compensation': allow" ``` -## Configuration - -Plugins are configured in YAML: +**Information-flow control**: session tainting that detects and blocks write-down, for example refusing an external send after the session touched secret data: ```yaml -plugin_dirs: - - ./plugins - -plugins: - - name: email_filter - kind: my_app.plugins.EmailFilterPlugin - version: 1.0.0 - hooks: - - email_pre_send - mode: sequential - priority: 10 - config: - allowed_domains: - - company.com - - partner.org +# get_compensation taints the session +policy: ["require(role.hr)", "taint(secret, session)"] +# send_email, later in the same session, refuses even with a clean body +policy: + - "require(perm.email_send)" + - "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')" ``` -### Priority - -Plugins are scheduled by mode, and execute in priority order within each phase (lower number = higher priority). Use this to ensure enforcement runs before transformation, and transformation runs before logging. - -**Plugin Scheduling** +The pipeline underneath (hooks, the plugin manager, execution modes) is the mechanism that runs policy effects. It is the supporting layer. APL is how you express intent; the pipeline is how that intent executes. Plugins are capability-gated, so an effect only sees the context it declares. -At each hook invocation, plugins are grouped and scheduled by execution mode, following a strict phase order: - -``` -sequential → transform → audit → concurrent → fire_and_forget -``` +## Where it runs -Within `sequential`, `transform`, and `audit` phases, plugins execute in **priority order** (lower number = higher priority, e.g., `10` runs before `20`). +CPEX is direction-agnostic. The same policy enforces whether CPEX sits in front of a tool server as a gateway, beside an agent as an egress sidecar, or inside an agent framework. Move the enforcement point; keep the policy. -### Conditions +## Install -Restrict plugins to specific contexts: +```bash +# Engine only (bring your own plugins): +cargo add cpex -```yaml -plugins: - - name: tenant_plugin - kind: my_app.plugins.TenantPlugin - hooks: - - tool_pre_invoke - mode: sequential - conditions: - - tenant_ids: [tenant-1, tenant-2] - server_ids: [server-prod] +# With the bundled builtin plugins/PDPs: +cargo add cpex --features builtins ``` -## Testing - -Plugins are plain async classes — test them directly: +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`. -```python -import pytest -from cpex.framework import PluginConfig, GlobalContext, PluginContext +```rust +use std::sync::Arc; +use cpex::PluginManager; -@pytest.mark.asyncio -async def test_email_filter_blocks_external_domain(): - config = PluginConfig( - name="test_filter", - kind="my_app.plugins.EmailFilterPlugin", - version="1.0.0", - hooks=["email_pre_send"], - config={"allowed_domains": ["company.com"]} - ) - plugin = EmailFilterPlugin(config) +let mgr = Arc::new(PluginManager::default()); - payload = EmailPayload(recipient="user@external.com", subject="Hello", body="...") - context = PluginContext(global_context=GlobalContext(request_id="test-1")) - - result = await plugin.block_external_domains(payload, context) - assert result.continue_processing is False - assert result.violation.code == "DOMAIN_BLOCKED" +// 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. ``` -## External Plugins +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. -Plugins can run as standalone services, connected over MCP (Streamable HTTP), gRPC, or Unix domain sockets. +## Documentation -```yaml -plugins: - - name: remote_validator - kind: external - hooks: - - tool_pre_invoke - mode: sequential - mcp: - proto: STREAMABLEHTTP - url: https://plugin-server.example.com - tls: - certfile: /path/to/client-cert.pem - keyfile: /path/to/client-key.pem - ca_bundle: /path/to/ca-bundle.pem -``` +- [**Vision**](https://contextforge-org.github.io/cpex/docs/vision/): the reference-monitor model and where CPEX sits. +- [**Overview**](https://contextforge-org.github.io/cpex/docs/overview/): the model in motion, with the scenario above end to end. +- [**APL**](https://contextforge-org.github.io/cpex/docs/apl/): the policy language: predicates, effects, sequencing, field pipelines. +- [**Quick Start**](https://contextforge-org.github.io/cpex/docs/quickstart/): stand up CPEX as an enforcement point. -Build an external plugin server with the built-in `ExternalPluginServer`: +## Workspace layout -```python -from cpex.framework import ExternalPluginServer +CPEX is a Cargo workspace of focused crates: -server = ExternalPluginServer(plugins=[MyPlugin(config)]) -server.run() -``` +| 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), and the Valkey session store | -## Isolated plugins +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). -Native plugins can be run in a separate python virtual environment (venv) to prevent them from interfering with the host environment. Plugin specific packages are automatically installed based on the contents of the supplied requirements_file. +## Development -```yaml - - name: "test_plugin" - kind: "isolated_venv" - version: "0.1.0" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin"] - mode: "sequential" - priority: 150 - conditions: - # Apply to specific tools/servers - - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - # Plugin config dict passed to the plugin constructor - class_name: "test_plugin.plugin.TestPlugin" - requirements_file: "requirements.txt" - # essentially the plugin folder hosting the plugin relative to the project root - script_path: "plugins" +CPEX targets Rust **1.96** (pinned in [`rust-toolchain.toml`](rust-toolchain.toml)). Common tasks: + +```bash +make lint # rustfmt --check + clippy -D warnings +make test # cargo test --workspace +make audit # cargo deny check (advisories, licenses, bans, sources) +make examples-build # build all Rust + Go examples +make ci # the full local gate (lint + test + examples) ``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow and [SECURITY.md](SECURITY.md) to report vulnerabilities. ## Project Status -CPEX is under active development as part of the [ContextForge](https://github.com/contextforge-org) ecosystem. The framework is designed to work across AI gateways, agent frameworks, LLM proxies, and tool servers. - -## Contributing - -Contributions are welcome. Open an issue, propose a plugin, or submit a pull request. +CPEX is under active development as part of the [ContextForge](https://github.com/contextforge-org) ecosystem. It is designed to work across AI gateways, agent frameworks, LLM proxies, and tool servers. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..c1c2c9e8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported Versions + +| Version | Track | Supported | +| ------- | ---------------- | ---------------------------------- | +| `0.2.x` | Rust (`main`) | Yes (current development line) | +| `0.1.x` | Python (`0.1.x`) | Maintainance mode (legacy) | +| `< 0.1` | — | No. | + +Only the latest patch release of each supported minor version receives fixes. +CPEX is pre-`1.0`; APIs and the security model may change between minor versions. + +## Reporting a Vulnerability + +**Please do not open a public issue for security vulnerabilities.** + +Report privately through GitHub's **Private Vulnerability Reporting**: + +1. Go to the [Security tab](https://github.com/contextforge-org/cpex/security) of the repository. +2. Click **Report a vulnerability** (or use + ). +3. Provide the details below. + +This opens a private advisory visible only to you and the maintainers. + +Please include: + +- A description of the vulnerability and its impact. +- Steps to reproduce (a minimal proof of concept helps). +- Affected versions, crates, and configuration (e.g. which features / builtins). +- Any mitigations or workarounds you have identified. + +## Response Process + +- We will acknowledge your report, typically within a few business days. +- We will investigate, keep you updated on progress, and coordinate a fix and + disclosure timeline with you. +- Prior to `v1.0.0` we work with reporters individually on timelines; we will + credit you in the advisory unless you prefer to remain anonymous. + +## Severity Classification + +- **Critical** — Remote code execution, authentication/authorization bypass, or + policy-enforcement bypass that defeats CPEX's core guarantees without user + interaction. +- **High** — Privilege escalation, significant data exposure, or denial of + service with amplification. +- **Medium** — Information disclosure of limited scope, or denial of service + requiring sustained effort. +- **Low** — Issues requiring unlikely configurations or with minimal impact. + +## Safe Harbor + +We consider security research conducted in good faith under this policy to be +authorized. We will not pursue legal action against researchers who follow it +and report findings responsibly. Thank you for helping keep CPEX secure. diff --git a/builtins/pdps/cedar-direct/Cargo.toml b/builtins/pdps/cedar-direct/Cargo.toml index 46108461..e5a8cd79 100644 --- a/builtins/pdps/cedar-direct/Cargo.toml +++ b/builtins/pdps/cedar-direct/Cargo.toml @@ -18,9 +18,15 @@ 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 = { path = "../../../crates/apl-core" } +apl-core = { path = "../../../crates/apl-core", version = "0.2.0" } # Permissive caret spec — `"4"` means "any 4.x that Cargo can find." # # Code-side note: we use `Request::new(...)` (added in 4.11 alongside @@ -48,11 +54,14 @@ tracing = { workspace = true } # 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 = { path = "../../../crates/apl-cmf" } -apl-cpex = { path = "../../../crates/apl-cpex" } -cpex-core = { path = "../../../crates/cpex-core" } +apl-cmf = { path = "../../../crates/apl-cmf", version = "0.2.0" } +apl-cpex = { path = "../../../crates/apl-cpex", version = "0.2.0" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } 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/decision.rs b/builtins/pdps/cedar-direct/src/decision.rs index d683abf2..46b8bf03 100644 --- a/builtins/pdps/cedar-direct/src/decision.rs +++ b/builtins/pdps/cedar-direct/src/decision.rs @@ -68,10 +68,7 @@ pub fn translate(response: &cedar_policy::Response, policy_set: &PolicySet) -> P }) .collect(); - let errors: Vec = diagnostics - .errors() - .map(|e| e.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 @@ -117,7 +114,7 @@ pub fn translate(response: &cedar_policy::Response, policy_set: &PolicySet) -> P reason: Some(reason), rule_source, } - } + }, }; PdpDecision { diff --git a/builtins/pdps/cedar-direct/src/entities.rs b/builtins/pdps/cedar-direct/src/entities.rs index 73cd526a..c6d30628 100644 --- a/builtins/pdps/cedar-direct/src/entities.rs +++ b/builtins/pdps/cedar-direct/src/entities.rs @@ -47,9 +47,8 @@ pub fn build( ) -> 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)) - }) + 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: diff --git a/builtins/pdps/cedar-direct/src/request.rs b/builtins/pdps/cedar-direct/src/request.rs index c4cfb1be..f0c75c22 100644 --- a/builtins/pdps/cedar-direct/src/request.rs +++ b/builtins/pdps/cedar-direct/src/request.rs @@ -85,9 +85,7 @@ pub fn parse<'a>( let resource_args = map .get(serde_yaml::Value::String("resource".to_string())) - .ok_or_else(|| { - PdpError::Dispatch("cedar:() `resource` missing".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, @@ -101,17 +99,13 @@ pub fn parse<'a>( 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 - )) + 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)) - })?; + 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 @@ -170,7 +164,10 @@ fn build_cpex_context(bag: &AttributeBag) -> Value { let mut security = Map::new(); if let Some(labels) = bag.get_string_set("security.labels") { - security.insert("labels".to_string(), json!(labels.iter().collect::>())); + 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)); diff --git a/builtins/pdps/cedar-direct/src/resolver.rs b/builtins/pdps/cedar-direct/src/resolver.rs index 392b1bd1..2a26e713 100644 --- a/builtins/pdps/cedar-direct/src/resolver.rs +++ b/builtins/pdps/cedar-direct/src/resolver.rs @@ -130,12 +130,12 @@ impl CedarDirectResolver { 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() @@ -147,12 +147,13 @@ impl CedarDirectResolver { 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, - })?; + let text = + std::fs::read_to_string(&path).map_err(|source| BuildError::SchemaFile { + path: path.clone(), + source, + })?; Some(parse_schema(&text)?) - } + }, (None, None) => None, }; @@ -204,11 +205,7 @@ impl PdpResolver for CedarDirectResolver { self.dialect.clone() } - async fn evaluate( - &self, - call: &PdpCall, - bag: &AttributeBag, - ) -> Result { + 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 @@ -300,20 +297,19 @@ fn build_principal_uid( }) } -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()) - })?; +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 - )) + PdpError::Dispatch(format!("failed to parse resource UID '{}': {}", uid_str, e)) }) } diff --git a/builtins/pdps/cedar-direct/src/template.rs b/builtins/pdps/cedar-direct/src/template.rs index 5466e287..b3e46074 100644 --- a/builtins/pdps/cedar-direct/src/template.rs +++ b/builtins/pdps/cedar-direct/src/template.rs @@ -70,21 +70,21 @@ pub fn resolve_refs( } 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()), } } @@ -128,7 +128,7 @@ fn substitute( .map(|s| serde_yaml::Value::String(s.clone())) .collect(); serde_yaml::Value::Sequence(items) - } + }, }) } @@ -233,7 +233,11 @@ email: ${claim.email} 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); + assert!( + msg.contains("args.missing"), + "error mentions the key: {}", + msg + ); } #[test] diff --git a/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs b/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs index a6098d31..85677dc2 100644 --- a/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs +++ b/builtins/pdps/cedar-direct/tests/basic_allow_deny.rs @@ -77,7 +77,7 @@ async fn empty_policy_set_denies_by_default() { 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"); @@ -102,7 +102,10 @@ async fn role_in_bag_reaches_principal_attributes() { // 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"); + 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()]); @@ -120,7 +123,7 @@ async fn role_in_bag_reaches_principal_attributes() { rule_source, "cedar.default_deny", "no permit matched → default-deny, not policy-attributed" ); - } + }, other => panic!("expected Deny for bob, got {:?}", other), } } @@ -146,7 +149,10 @@ async fn forbid_attribution_carries_policy_id() { .expect("evaluate"); match decision.decision { - Decision::Deny { rule_source, reason } => { + Decision::Deny { + rule_source, + reason, + } => { assert_eq!( rule_source, "blocklist", "violation should be attributed to the forbid policy by id" @@ -156,7 +162,7 @@ async fn forbid_attribution_carries_policy_id() { "reason should mention the firing policy: {:?}", reason ); - } + }, other => panic!("expected Deny via blocklist, got {:?}", other), } assert!(decision.diagnostics.iter().any(|d| d == "blocklist")); @@ -216,5 +222,8 @@ async fn with_dialect_overrides_default() { .expect("policy parses") .with_dialect(PdpDialect::Custom("workload".to_string())); - assert_eq!(resolver.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 index 40c25f65..aabd1ce2 100644 --- a/builtins/pdps/cedar-direct/tests/small_stack_eval.rs +++ b/builtins/pdps/cedar-direct/tests/small_stack_eval.rs @@ -44,8 +44,7 @@ fn evaluate_succeeds_on_musl_sized_thread_stack() { @id("allow-all") permit(principal, action, resource); "#; - let resolver = - CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); let call = PdpCall { dialect: PdpDialect::Cedar, diff --git a/builtins/pdps/cel/Cargo.toml b/builtins/pdps/cel/Cargo.toml index 2dbce9c9..ccd1e595 100644 --- a/builtins/pdps/cel/Cargo.toml +++ b/builtins/pdps/cel/Cargo.toml @@ -18,9 +18,15 @@ 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 = { path = "../../../crates/apl-core" } +apl-core = { path = "../../../crates/apl-core", version = "0.2.0" } # 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 @@ -51,7 +57,10 @@ tracing = { workspace = true } # 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 = { path = "../../../crates/apl-cmf" } -apl-cpex = { path = "../../../crates/apl-cpex" } -cpex-core = { path = "../../../crates/cpex-core" } +apl-cmf = { path = "../../../crates/apl-cmf", version = "0.2.0" } +apl-cpex = { path = "../../../crates/apl-cpex", version = "0.2.0" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } 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 index e7b5d851..87cc9785 100644 --- a/builtins/pdps/cel/src/activation.rs +++ b/builtins/pdps/cel/src/activation.rs @@ -121,17 +121,19 @@ fn insert(level: &mut BTreeMap, full_key: &str, segments: &[&str], "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())); + let entry = level + .entry(head) + .or_insert_with(|| Node::Branch(BTreeMap::new())); if let Node::Leaf(_) = entry { tracing::warn!( key = %full_key, @@ -155,7 +157,7 @@ fn node_to_value(node: Node) -> Value { .map(|(k, child)| (k, node_to_value(child))) .collect(); Value::from(map) - } + }, } } @@ -185,7 +187,7 @@ fn attr_to_value(attr: &AttributeValue) -> Value { sorted.sort(); let items: Vec = sorted.into_iter().map(|s| Value::from(s.clone())).collect(); Value::from(items) - } + }, } } @@ -216,12 +218,12 @@ fn yaml_to_value(v: &serde_yaml::Value) -> Value { } 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 { @@ -230,7 +232,7 @@ fn yaml_to_value(v: &serde_yaml::Value) -> Value { } } Value::from(out) - } + }, // serde_yaml's tagged values are not used in APL configs; treat as null. _ => Value::Null, } @@ -347,7 +349,10 @@ mod tests { let ctx = bag_to_context(&bag, &args); // Author-supplied `resource` is visible. assert!(matches!( - run_cel("resource.kind == 'document' && resource.sensitivity == 3", &ctx), + run_cel( + "resource.kind == 'document' && resource.sensitivity == 3", + &ctx + ), Ok(Value::Bool(true)) )); // `subject` from the bag wins over the args' `subject: shadowed`. diff --git a/builtins/pdps/cel/src/resolver.rs b/builtins/pdps/cel/src/resolver.rs index 240f6559..fdb5c2e6 100644 --- a/builtins/pdps/cel/src/resolver.rs +++ b/builtins/pdps/cel/src/resolver.rs @@ -242,7 +242,7 @@ impl CelResolver { return Err(BuildError::ConfigShape(format!( "`on_error` must be `deny` or `allow`, got `{other}`" ))); - } + }, }; Ok(Self::new().with_on_error(on_error)) @@ -308,8 +308,11 @@ impl CelResolver { "CEL runtime error; on_error=allow → allowing through. \ This is fail-open behavior; verify it is intentional." ); - PdpDecision { decision: Decision::Allow, diagnostics: vec![cause] } - } + PdpDecision { + decision: Decision::Allow, + diagnostics: vec![cause], + } + }, OnError::Deny => PdpDecision { decision: Decision::Deny { reason: Some(cause.clone()), @@ -374,11 +377,7 @@ impl PdpResolver for CelResolver { self.dialect.clone() } - async fn evaluate( - &self, - call: &PdpCall, - bag: &AttributeBag, - ) -> Result { + 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 @@ -387,9 +386,7 @@ impl PdpResolver for CelResolver { .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(), - ) + PdpError::Dispatch("cel:() step requires a string `expr` argument".to_string()) })?; // 2. Compile (cached). Compile errors always Deny (an author @@ -399,10 +396,10 @@ impl PdpResolver for CelResolver { 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 @@ -436,10 +433,11 @@ impl PdpResolver for CelResolver { }, diagnostics, }) - } - Ok(other) => Ok(self.on_error_decision(format!( - "CEL expression must return bool, got {other:?}" - ))), + }, + 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 @@ -467,7 +465,7 @@ impl PdpResolver for CelResolver { )); } Ok(self.on_error_decision(cause)) - } + }, } } } @@ -477,17 +475,13 @@ impl PdpResolver for CelResolver { /// 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 { +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 referenced_set: std::collections::HashSet<&str> = referenced.iter().copied().collect(); let mut snapshot: Vec = bag .iter() @@ -547,10 +541,16 @@ mod tests { let r = CelResolver::new(); let bag = bag_with(&[("subject.id", "alice")]); - let allow = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + 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(); + let deny = r + .evaluate(&cel_call("subject.id == 'bob'"), &bag) + .await + .unwrap(); assert!(matches!(deny.decision, Decision::Deny { .. })); } @@ -572,23 +572,21 @@ mod tests { async fn custom_function_registration_round_trips() { let r = CelResolver::new() .with_functions(|ctx| { - ctx.add_function( - "double", - |n: i64| -> i64 { n * 2 }, - ); + ctx.add_function("double", |n: i64| -> i64 { n * 2 }); }) .with_functions(|ctx| { - ctx.add_function( - "shout", - |s: Arc| -> String { s.to_uppercase() }, - ); + 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("double(21) == 42"), &bag).await.unwrap(); + let out = r + .evaluate(&cel_call("double(21) == 42"), &bag) + .await + .unwrap(); assert_eq!( - out.decision, Decision::Allow, + out.decision, + Decision::Allow, "first registered function must be callable", ); @@ -598,7 +596,8 @@ mod tests { .await .unwrap(); assert_eq!( - out.decision, Decision::Allow, + out.decision, + Decision::Allow, "subsequent with_functions calls must compose, not replace", ); } @@ -615,7 +614,8 @@ mod tests { .await .unwrap(); assert_eq!( - out.decision, Decision::Allow, + out.decision, + Decision::Allow, "the regex CEL feature must be enabled so authors can match paths", ); } @@ -624,7 +624,10 @@ mod tests { 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(); + let out = r + .evaluate(&cel_call("nonexistent.field == 1"), &AttributeBag::new()) + .await + .unwrap(); assert!(matches!(out.decision, Decision::Deny { .. })); } @@ -692,16 +695,22 @@ mod tests { 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(); + let out = r + .evaluate(&cel_call("1 +"), &AttributeBag::new()) + .await + .unwrap(); match out.decision { - Decision::Deny { reason, rule_source } => { + 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:?}"), } } @@ -709,7 +718,10 @@ mod tests { #[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(); + let out = r + .evaluate(&cel_call("nonexistent.field == 1"), &AttributeBag::new()) + .await + .unwrap(); assert_eq!(out.decision, Decision::Allow); } @@ -745,12 +757,18 @@ mod tests { let bag = bag_with(&[("subject.id", "alice")]); // First expr fills the cache. - let first = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + 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(); + 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", @@ -767,7 +785,10 @@ mod tests { ); // Cached expr still works. - let third = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + let third = r + .evaluate(&cel_call("subject.id == 'alice'"), &bag) + .await + .unwrap(); assert_eq!(third.decision, Decision::Allow); } @@ -782,25 +803,29 @@ mod tests { let bag = bag_with(&[("subject.id", "alice")]); // Fill the cache. - let _ = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + 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(); + 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 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(); + 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(_)) @@ -814,8 +839,7 @@ mod tests { /// 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(); + 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"), @@ -861,7 +885,11 @@ mod tests { // 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_eq!( + cache.len(), + 1, + "concurrent compiles must converge to one entry" + ); assert!(cache.contains_key(expr)); } } diff --git a/builtins/plugins/audit-logger/Cargo.toml b/builtins/plugins/audit-logger/Cargo.toml index 451dcaf0..95cbc3ce 100644 --- a/builtins/plugins/audit-logger/Cargo.toml +++ b/builtins/plugins/audit-logger/Cargo.toml @@ -14,9 +14,15 @@ 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 = { path = "../../../crates/cpex-core" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } async-trait = { workspace = true } chrono = { workspace = true } @@ -27,3 +33,6 @@ 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/factory.rs b/builtins/plugins/audit-logger/src/factory.rs index 38fa039d..2ef785c5 100644 --- a/builtins/plugins/audit-logger/src/factory.rs +++ b/builtins/plugins/audit-logger/src/factory.rs @@ -40,9 +40,8 @@ impl PluginFactory for AuditLoggerFactory { .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)), - ); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&logger))); (leaked, adapter) }) .collect(); diff --git a/builtins/plugins/audit-logger/src/logger.rs b/builtins/plugins/audit-logger/src/logger.rs index b02b720f..aade654c 100644 --- a/builtins/plugins/audit-logger/src/logger.rs +++ b/builtins/plugins/audit-logger/src/logger.rs @@ -105,7 +105,7 @@ impl AuditLogger { }), ); break; - } + }, ContentPart::PromptRequest { content } => { record.insert( "prompt_request".into(), @@ -115,8 +115,8 @@ impl AuditLogger { }), ); break; - } - _ => {} + }, + _ => {}, } } @@ -153,10 +153,10 @@ impl AuditLogger { AuditDestination::Stderr => { // One JSON line — easy to grep / forward / jq through. eprintln!("{}", record); - } + }, AuditDestination::Tracing => { tracing::info!(target: "apl.audit", record = %record, "audit"); - } + }, } } } diff --git a/builtins/plugins/delegator-biscuit/Cargo.toml b/builtins/plugins/delegator-biscuit/Cargo.toml index 06d33507..974fae03 100644 --- a/builtins/plugins/delegator-biscuit/Cargo.toml +++ b/builtins/plugins/delegator-biscuit/Cargo.toml @@ -40,10 +40,16 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true +description = "CPEX delegation handler — Biscuit capability-token attenuation." +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true [dependencies] -apl-core = { path = "../../../crates/apl-core" } -cpex-core = { path = "../../../crates/cpex-core" } +apl-core = { path = "../../../crates/apl-core", version = "0.2.0" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } # biscuit-auth v6 — current major. Maintained by Clever Cloud + # community. Ed25519 + Datalog. No default-features off needed; the @@ -65,3 +71,6 @@ chrono = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/builtins/plugins/delegator-biscuit/src/config.rs b/builtins/plugins/delegator-biscuit/src/config.rs index 742014cb..17c56010 100644 --- a/builtins/plugins/delegator-biscuit/src/config.rs +++ b/builtins/plugins/delegator-biscuit/src/config.rs @@ -68,12 +68,11 @@ impl PublicKeySource { let bytes = hex::decode(hex.trim()) .map_err(|e| format!("public_key.hex isn't valid hex: {e}"))?; Self::bytes_to_public_key(&bytes) - } + }, Self::Bytes { bytes } => Self::bytes_to_public_key(bytes), Self::File { path } => { - let raw = std::fs::read(path).map_err(|e| { - format!("public_key file '{}' unreadable: {e}", path.display()) - })?; + let raw = std::fs::read(path) + .map_err(|e| format!("public_key file '{}' unreadable: {e}", path.display()))?; // File might be raw 32 bytes OR a hex string (with // optional whitespace). Try raw first; fall back to // hex if the length doesn't match. @@ -90,14 +89,11 @@ impl PublicKeySource { })?; let trimmed = as_str.trim(); let bytes = hex::decode(trimmed).map_err(|e| { - format!( - "public_key file '{}' isn't valid hex: {e}", - path.display() - ) + format!("public_key file '{}' isn't valid hex: {e}", path.display()) })?; Self::bytes_to_public_key(&bytes) } - } + }, } } diff --git a/builtins/plugins/delegator-biscuit/src/delegator.rs b/builtins/plugins/delegator-biscuit/src/delegator.rs index b1fdfa9e..0f7233bc 100644 --- a/builtins/plugins/delegator-biscuit/src/delegator.rs +++ b/builtins/plugins/delegator-biscuit/src/delegator.rs @@ -63,7 +63,10 @@ impl std::fmt::Debug for BiscuitDelegator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BiscuitDelegator") .field("cfg", &self.cfg.name) - .field("default_outbound_header", &self.typed.default_outbound_header) + .field( + "default_outbound_header", + &self.typed.default_outbound_header, + ) .field("default_ttl_seconds", &self.typed.default_ttl_seconds) .field("root_public_key", &"") .finish() @@ -82,15 +85,14 @@ impl BiscuitDelegator { ), }) })?; - let typed: BiscuitDelegatorConfig = serde_json::from_value(raw.clone()) - .map_err(|e| { - Box::new(PluginError::Config { - message: format!( - "plugin '{}' (cpex-plugin-delegator-biscuit) config parse failed: {e}", - cfg.name - ), - }) - })?; + let typed: BiscuitDelegatorConfig = serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (cpex-plugin-delegator-biscuit) config parse failed: {e}", + cfg.name + ), + }) + })?; let root_public_key = typed.root_public_key.resolve().map_err(|e| { Box::new(PluginError::Config { @@ -161,14 +163,12 @@ impl HookHandler for BiscuitDelegator { root public key: {e}" ), )); - } + }, }; // 2. Build the delegation block. let ttl_secs = self.effective_ttl_seconds(payload); - let expires_at_unix = (Utc::now() - + chrono::Duration::seconds(ttl_secs as i64)) - .timestamp(); + let expires_at_unix = (Utc::now() + chrono::Duration::seconds(ttl_secs as i64)).timestamp(); // Build the delegation block as a Datalog string. biscuit // parses + validates the Datalog at parse time. Building @@ -194,9 +194,7 @@ impl HookHandler for BiscuitDelegator { )); } // Time-bound check — token unusable past expires_at. - datalog.push_str(&format!( - "check if time($t), $t <= {expires_at_unix};" - )); + datalog.push_str(&format!("check if time($t), $t <= {expires_at_unix};")); // biscuit-auth 6's `BlockBuilder::code` consumes the // builder and returns a new one on success (or an error if @@ -208,7 +206,7 @@ impl HookHandler for BiscuitDelegator { "delegation.attenuation_failed", format!("delegation block Datalog parse failed: {e}"), )); - } + }, }; // 3. Append the block. Biscuit generates an ephemeral @@ -221,7 +219,7 @@ impl HookHandler for BiscuitDelegator { "delegation.attenuation_failed", format!("biscuit append failed: {e}"), )); - } + }, }; // 4. Serialize. @@ -232,7 +230,7 @@ impl HookHandler for BiscuitDelegator { "delegation.attenuation_failed", format!("could not serialize attenuated biscuit: {e}"), )); - } + }, }; // 5. Build RawDelegatedToken. diff --git a/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs b/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs index 1989b64e..70033dfe 100644 --- a/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs +++ b/builtins/plugins/delegator-biscuit/tests/biscuit_e2e.rs @@ -93,7 +93,12 @@ async fn build_manager() -> Arc { mgr } -fn build_payload(inbound: String, target: &str, audience: &str, perms: &[&str]) -> DelegationPayload { +fn build_payload( + inbound: String, + target: &str, + audience: &str, + perms: &[&str], +) -> DelegationPayload { DelegationPayload::new(inbound, target) .with_target_type(TargetType::Tool) .with_target_audience(audience) @@ -173,8 +178,8 @@ async fn happy_path_attenuates_biscuit() { .expect("attenuated biscuit verifies against root"); // The new biscuit should have one more block than the original. - let original = Biscuit::from_base64(&inbound, roots().keypair.public()) - .expect("inbound verifies"); + let original = + Biscuit::from_base64(&inbound, roots().keypair.public()).expect("inbound verifies"); assert_eq!(attenuated.block_count(), original.block_count() + 1); // Authorize against the matching operation — should succeed @@ -272,8 +277,8 @@ async fn wrong_root_key_rejects() { #[tokio::test] async fn empty_bearer_token_rejects() { let mgr = build_manager().await; - let payload = DelegationPayload::new("", "tool") - .with_target_audience("https://downstream.example.com"); + let payload = + DelegationPayload::new("", "tool").with_target_audience("https://downstream.example.com"); let result = invoke(&mgr, payload).await; assert!(!result.continue_processing); diff --git a/builtins/plugins/delegator-oauth/Cargo.toml b/builtins/plugins/delegator-oauth/Cargo.toml index a83c457e..19f0f3be 100644 --- a/builtins/plugins/delegator-oauth/Cargo.toml +++ b/builtins/plugins/delegator-oauth/Cargo.toml @@ -35,10 +35,16 @@ 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] -apl-core = { path = "../../../crates/apl-core" } -cpex-core = { path = "../../../crates/cpex-core" } +apl-core = { path = "../../../crates/apl-core", version = "0.2.0" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } # `reqwest` for the HTTP POST to the IdP token endpoint. Default # features pull `rustls` for TLS — we explicitly disable the @@ -67,3 +73,6 @@ tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } # 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 index d7aa9a22..e01b3a67 100644 --- a/builtins/plugins/delegator-oauth/src/config.rs +++ b/builtins/plugins/delegator-oauth/src/config.rs @@ -109,13 +109,12 @@ impl ClientSecretSource { /// 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::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()) - }), + .map_err(|e| format!("secret file '{}' unreadable: {e}", path.display())), Self::Literal { secret } => Ok(secret.clone()), } } diff --git a/builtins/plugins/delegator-oauth/src/delegator.rs b/builtins/plugins/delegator-oauth/src/delegator.rs index f95f7bc9..3dd99e83 100644 --- a/builtins/plugins/delegator-oauth/src/delegator.rs +++ b/builtins/plugins/delegator-oauth/src/delegator.rs @@ -58,14 +58,12 @@ 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"; +const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; /// 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"; +const DEFAULT_ISSUED_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; /// OAuth-mediated `TokenDelegate` handler. pub struct OAuthDelegator { @@ -102,15 +100,14 @@ impl OAuthDelegator { ), }) })?; - 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 - ), - }) - })?; + 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 { @@ -272,7 +269,7 @@ impl HookHandler for OAuthDelegator { "delegation.idp_timeout", format!("token-exchange to {} timed out", self.typed.token_endpoint), )); - } + }, Err(e) => { return PluginResult::deny(PluginViolation::new( "delegation.idp_unreachable", @@ -281,7 +278,7 @@ impl HookHandler for OAuthDelegator { self.typed.token_endpoint, ), )); - } + }, }; let status = response.status(); @@ -289,8 +286,7 @@ impl HookHandler for OAuthDelegator { // 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) - { + let (code, reason) = match serde_json::from_str::(&body) { Ok(err) => { let mut reason = err.error.clone(); if let Some(desc) = err.error_description { @@ -298,7 +294,7 @@ impl HookHandler for OAuthDelegator { reason.push_str(&desc); } ("delegation.idp_rejected", reason) - } + }, Err(_) => ( "delegation.idp_rejected", format!("IdP returned {status}: {body}"), @@ -314,7 +310,7 @@ impl HookHandler for OAuthDelegator { "delegation.bad_response", format!("IdP response wasn't valid token-exchange JSON: {e}"), )); - } + }, }; // Compute effective scopes. IdP's `scope` field wins (it diff --git a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs index 45e6a717..734ac307 100644 --- a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs +++ b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs @@ -215,11 +215,7 @@ async fn idp_rejection_surfaces_error_code() { .await; let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "tool", - "https://downstream.example.com", - &["read"], - ); + let payload = build_payload("tool", "https://downstream.example.com", &["read"]); let result = invoke(&mgr, payload).await; assert!(!result.continue_processing); @@ -245,11 +241,7 @@ async fn idp_unreachable_surfaces_violation() { // 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 payload = build_payload("tool", "https://downstream.example.com", &["read"]); let result = invoke(&mgr, payload).await; assert!(!result.continue_processing); @@ -270,8 +262,8 @@ async fn idp_unreachable_surfaces_violation() { #[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 payload = + DelegationPayload::new("", "tool").with_target_audience("https://downstream.example.com"); let result = invoke(&mgr, payload).await; assert!(!result.continue_processing); @@ -321,11 +313,7 @@ async fn idp_narrower_scope_surfaces_scope_too_broad() { .await; let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "tool", - "https://downstream.example.com", - &["read", "write"], - ); + let payload = build_payload("tool", "https://downstream.example.com", &["read", "write"]); let result = invoke(&mgr, payload).await; assert!( @@ -366,11 +354,7 @@ async fn idp_exact_scope_match_succeeds() { .await; let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - let payload = build_payload( - "tool", - "https://downstream.example.com", - &["read", "write"], - ); + let payload = build_payload("tool", "https://downstream.example.com", &["read", "write"]); let result = invoke(&mgr, payload).await; assert!( diff --git a/builtins/plugins/identity-jwt/Cargo.toml b/builtins/plugins/identity-jwt/Cargo.toml index 11ba79e0..c2696ff0 100644 --- a/builtins/plugins/identity-jwt/Cargo.toml +++ b/builtins/plugins/identity-jwt/Cargo.toml @@ -21,10 +21,16 @@ 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] -apl-core = { path = "../../../crates/apl-core" } -cpex-core = { path = "../../../crates/cpex-core" } +apl-core = { path = "../../../crates/apl-core", version = "0.2.0" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } # `jsonwebtoken` is the de facto JWT library for Rust. ~5 transitive # deps (ring, base64, serde, pem). Supports RS256/RS384/RS512, @@ -79,3 +85,6 @@ rsa = { version = "0.9", features = ["pem"] } # implementing `rand_core::CryptoRngCore` — `rand::thread_rng()` # satisfies that. Test-only dep. rand = "0.8" + +[lints] +workspace = true diff --git a/builtins/plugins/identity-jwt/src/claim_map.rs b/builtins/plugins/identity-jwt/src/claim_map.rs index ab6c1878..bda5aa9e 100644 --- a/builtins/plugins/identity-jwt/src/claim_map.rs +++ b/builtins/plugins/identity-jwt/src/claim_map.rs @@ -138,8 +138,8 @@ impl ClaimMapper for StandardClaimMap { client.authorized_audiences.push(s.to_string()); } } - } - _ => {} + }, + _ => {}, } // Platform-native roles. @@ -388,7 +388,10 @@ mod tests { "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("email"), + Some(&"alice@corp.com".to_string()) + ); assert_eq!( subject.claims.get("preferred_username"), Some(&"alice".to_string()), diff --git a/builtins/plugins/identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs index dfd786a3..0efeb175 100644 --- a/builtins/plugins/identity-jwt/src/config.rs +++ b/builtins/plugins/identity-jwt/src/config.rs @@ -178,7 +178,7 @@ impl DecodingKeySource { match self { Self::JwksUrl { refresh_secs, .. } => { Some(std::time::Duration::from_secs(*refresh_secs)) - } + }, _ => None, } } @@ -199,16 +199,17 @@ impl DecodingKeySource { 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()))?; + 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)) @@ -235,7 +236,9 @@ impl DecodingKeySource { /// rolls don't require a gateway restart. pub async fn build_async(&self) -> Result { match self { - Self::JwksUrl { url, insecure_http, .. } => { + 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. @@ -292,7 +295,7 @@ impl DecodingKeySource { _ => { skipped_no_kid += 1; continue; - } + }, }; match DecodingKey::from_jwk(k) { Ok(key) => entries.push((kid, key)), @@ -309,7 +312,7 @@ impl DecodingKeySource { )); } 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. @@ -339,8 +342,8 @@ fn build_from_pem_bytes(bytes: &[u8], origin: &str) -> Result Result { - let parsed: jsonwebtoken::jwk::Jwk = serde_json::from_value(jwk.clone()) - .map_err(|e| format!("JWK is not well-formed: {e}"))?; + 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}")) } diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index d5447bb1..a0a43538 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -47,9 +47,7 @@ pub mod resolver; pub mod trusted_issuer; pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; -pub use config::{ - DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig, -}; +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 index 699893c5..00234a50 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -138,8 +138,8 @@ impl JwtIdentityResolver { }) })?; - let typed: JwtIdentityResolverConfig = serde_json::from_value(raw_config.clone()) - .map_err(|e| { + 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}", @@ -199,7 +199,7 @@ impl JwtIdentityResolver { cfg.name ), })); - } + }, }; // Reject `role: Custom(...)` at construction — the framework @@ -336,7 +336,7 @@ impl Plugin for JwtIdentityResolver { algorithms: cfg.algorithms.clone(), leeway_seconds: cfg.leeway_seconds, } - } + }, }; // Spawn refresh task. The closure owns: @@ -374,7 +374,7 @@ impl Plugin for JwtIdentityResolver { issuer = %issuer_label, "JWKS refresh succeeded" ); - } + }, Err(e) => { tracing::warn!( plugin = %plugin_label, @@ -382,7 +382,7 @@ impl Plugin for JwtIdentityResolver { error = %e, "JWKS refresh failed; keeping previous KeyStore" ); - } + }, } } }); @@ -395,10 +395,7 @@ impl Plugin for JwtIdentityResolver { // 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()); + let mut tasks = self.refresh_tasks.lock().unwrap_or_else(|p| p.into_inner()); tasks.extend(new_tasks); Ok(()) @@ -432,7 +429,7 @@ impl HookHandler for JwtIdentityResolver { self.header, self.cfg.name, self.role ), )); - } + }, }; if raw_token.is_empty() { return PluginResult::deny(PluginViolation::new( @@ -449,7 +446,7 @@ impl HookHandler for JwtIdentityResolver { "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. @@ -466,7 +463,7 @@ impl HookHandler for JwtIdentityResolver { "auth.untrusted_issuer", format!("issuer '{iss}' is not in the trusted-issuer list"), )); - } + }, }; // 2. Validate signature + standard claims, after kid-driven @@ -491,7 +488,7 @@ impl HookHandler for JwtIdentityResolver { yet succeeded; check upstream IdP reachability" ), )); - } + }, Err(ValidateError::UnknownKid(kid)) => { let reason = match kid { Some(k) => format!( @@ -501,11 +498,11 @@ impl HookHandler for JwtIdentityResolver { .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 @@ -520,7 +517,7 @@ impl HookHandler for JwtIdentityResolver { "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), @@ -530,7 +527,7 @@ impl HookHandler for JwtIdentityResolver { "claim mapper produced no client — required `client_id` \ / `azp` claim missing", )); - } + }, }, TokenRole::Workload => match self.claim_mapper.map_workload(&token_data.claims) { Some(w) => updated.caller_workload = Some(w), @@ -540,7 +537,7 @@ impl HookHandler for JwtIdentityResolver { "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. @@ -548,7 +545,7 @@ impl HookHandler for JwtIdentityResolver { "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 @@ -558,7 +555,7 @@ impl HookHandler for JwtIdentityResolver { "auth.misconfigured", "unsupported TokenRole variant", )); - } + }, } // 4. Stash the raw token for forwarding plugins. Key the @@ -703,9 +700,7 @@ fn classify_jwt_error(e: &jsonwebtoken::errors::Error) -> (&'static str, String) 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::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => "auth.algorithm_mismatch", ErrorKind::Base64(_) | ErrorKind::Json(_) => "auth.malformed_header", _ => "auth.token_invalid", }; @@ -747,8 +742,7 @@ mod tests { #[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"); + let err = JwtIdentityResolver::new(cfg).expect_err("empty trusted_issuers should fail"); assert!(format!("{err}").contains("trusted_issuers")); } @@ -765,8 +759,7 @@ mod tests { "claim_mapper": "made-up-mapper", }), ); - let err = JwtIdentityResolver::new(cfg) - .expect_err("unknown mapper should fail"); + let err = JwtIdentityResolver::new(cfg).expect_err("unknown mapper should fail"); assert!(format!("{err}").contains("claim_mapper")); } diff --git a/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs index 4ce89051..c03162b1 100644 --- a/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs @@ -46,10 +46,8 @@ const AUD: &str = "test-api"; /// 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()); + 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", @@ -68,8 +66,8 @@ fn mint_jwt(private_pem: &str, claims: Value) -> String { // ("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"); + let key = + EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("build EncodingKey from RSA PEM"); encode(&header, &claims, &key).expect("sign JWT") } @@ -300,8 +298,8 @@ fn build_jwks_two_keys( 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"); + let key = + EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("build EncodingKey from RSA PEM"); encode(&header, &claims, &key).expect("sign JWT") } @@ -497,7 +495,7 @@ async fn jwks_fetch_times_out_when_endpoint_stalls() { // deadline). We accept any Err outcome and rely on elapsed // time as the contract. match outcome { - Err(_e) => {} + Err(_e) => {}, Ok(_store) => panic!("stalled JWKS must not produce a KeyStore"), } // 5s overall timeout + 2s margin for setup / scheduler jitter. @@ -544,7 +542,9 @@ async fn jwks_unreachable_at_initialize_soft_fails() { // 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"); + 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 @@ -715,7 +715,10 @@ async fn jwks_refresh_picks_up_rotated_key() { None, ) .await; - assert!(!pre.continue_processing, "key-b token should not validate before refresh"); + assert!( + !pre.continue_processing, + "key-b token should not validate before refresh" + ); assert_eq!( pre.violation.expect("violation").code, "auth.unknown_kid", diff --git a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs index 8ecba75d..a82b1417 100644 --- a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs @@ -166,8 +166,8 @@ async fn valid_jwt_resolves_subject() { result.violation, ); - let identity = IdentityPayload::from_pipeline_result(&result) - .expect("payload should be present"); + 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")); diff --git a/builtins/plugins/pii-scanner/Cargo.toml b/builtins/plugins/pii-scanner/Cargo.toml index c5ae6ed9..b67214fb 100644 --- a/builtins/plugins/pii-scanner/Cargo.toml +++ b/builtins/plugins/pii-scanner/Cargo.toml @@ -13,9 +13,15 @@ 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 = { path = "../../../crates/cpex-core" } +cpex-core = { path = "../../../crates/cpex-core", version = "0.2.0" } async-trait = { workspace = true } serde = { workspace = true } @@ -26,3 +32,6 @@ 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/factory.rs b/builtins/plugins/pii-scanner/src/factory.rs index ce7a5016..e6abdc10 100644 --- a/builtins/plugins/pii-scanner/src/factory.rs +++ b/builtins/plugins/pii-scanner/src/factory.rs @@ -55,9 +55,8 @@ impl PluginFactory for PiiScannerFactory { // 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)), - ); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&scanner))); (leaked, adapter) }) .collect(); diff --git a/builtins/plugins/pii-scanner/src/scanner.rs b/builtins/plugins/pii-scanner/src/scanner.rs index e87039d0..b2248867 100644 --- a/builtins/plugins/pii-scanner/src/scanner.rs +++ b/builtins/plugins/pii-scanner/src/scanner.rs @@ -40,18 +40,21 @@ impl PiiScanner { ), }) })?; - 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 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 }) + Ok(Self { + cfg, + typed, + patterns, + }) } /// Scan every string value in the message's structured content @@ -69,20 +72,20 @@ impl PiiScanner { 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 + }, + _ => {}, // images / video / audio / etc. — out of scope for v0 } } None @@ -117,18 +120,18 @@ impl PiiScanner { 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(); } - } - _ => {} + }, + _ => {}, } } } @@ -193,20 +196,18 @@ impl HookHandler for PiiScanner { 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 \ + (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) - } + }, } } } @@ -257,9 +258,10 @@ mod tests { #[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 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"); @@ -271,9 +273,10 @@ mod tests { #[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 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); @@ -310,9 +313,7 @@ mod tests { PiiScanMode::Deny, )) .unwrap(); - let payload = message_with_args(HashMap::from([ - ("ref".to_string(), json!("INT-ABC123")), - ])); + 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); diff --git a/builtins/session/valkey/Cargo.toml b/builtins/session/valkey/Cargo.toml index b52a1983..2622015c 100644 --- a/builtins/session/valkey/Cargo.toml +++ b/builtins/session/valkey/Cargo.toml @@ -22,9 +22,15 @@ 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 = { path = "../../../crates/apl-cpex" } +apl-cpex = { path = "../../../crates/apl-cpex", version = "0.2.0" } async-trait = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } @@ -60,3 +66,6 @@ tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } # `#[ignore]`d by default and run via a dedicated job (see tests/). testcontainers-modules = { version = "0.13", features = ["valkey"] } testcontainers = "0.25" + +[lints] +workspace = true diff --git a/builtins/session/valkey/src/config.rs b/builtins/session/valkey/src/config.rs index 8b335129..fc9c0245 100644 --- a/builtins/session/valkey/src/config.rs +++ b/builtins/session/valkey/src/config.rs @@ -349,7 +349,10 @@ mod tests { 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}"); + assert!( + url.starts_with("redis://:s3cret@localhost:6379"), + "url: {url}" + ); } #[test] diff --git a/builtins/session/valkey/src/store.rs b/builtins/session/valkey/src/store.rs index a16c15af..0c847abc 100644 --- a/builtins/session/valkey/src/store.rs +++ b/builtins/session/valkey/src/store.rs @@ -106,7 +106,7 @@ impl SessionStore for ValkeySessionStore { return Err(SessionStoreError::Backend( "valkey SMEMBERS timed out".to_string(), )) - } + }, }; // Sliding-TTL refresh is fail-open for the read: the labels were @@ -160,7 +160,7 @@ impl SessionStore for ValkeySessionStore { 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 index 28aeaaa3..744de6e6 100644 --- a/builtins/session/valkey/tests/valkey_store_integration.rs +++ b/builtins/session/valkey/tests/valkey_store_integration.rs @@ -53,7 +53,7 @@ async fn valkey_target() -> Option { 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)"); @@ -62,7 +62,7 @@ async fn valkey_target() -> Option { "SKIPPED: no Valkey available ({e}); set VALKEY_TEST_URL or REQUIRE_VALKEY_TESTS=1" ); None - } + }, } } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..aee980c9 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,30 @@ +# Clippy thresholds and method blocklist. Lint *levels* live in +# [workspace.lints.clippy] in Cargo.toml; this file only tunes the knobs the +# complexity/style lints read. +too-many-lines-threshold = 100 +cognitive-complexity-threshold = 25 +too-many-arguments-threshold = 8 +max-fn-params-bools = 2 +max-struct-bools = 3 +enum-variant-size-threshold = 256 +type-complexity-threshold = 250 +# Keep in sync with rust-toolchain.toml `channel`. +msrv = "1.96" +avoid-breaking-exported-api = false + +# Identifiers clippy::doc_markdown should not flag as needing backticks. +doc-valid-idents = [ + "CPEX", "ContextForge", + "APL", "CMF", "PDP", "PEP", + "Cedar", "CEL", "Biscuit", + "JWT", "OAuth", "OIDC", "mTLS", + "PII", "Valkey", "Redis", + "FFI", "ABI", "cdylib", "staticlib", + "gRPC", "WASM", "WebAssembly", "MessagePack", + "OpenTelemetry", "IPv4", "IPv6", "MiB", +] + +# Method blocklist enforced by the `disallowed_methods` lint. +disallowed-methods = [ + { path = "std::thread::sleep", reason = "use tokio::time::sleep in async contexts to avoid blocking the runtime" }, +] diff --git a/cpex/__init__.py b/cpex/__init__.py deleted file mode 100644 index 7fd3e1aa..00000000 --- a/cpex/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -ContextForge Plugin Framework and Utilities Package. -""" diff --git a/cpex/framework/__init__.py b/cpex/framework/__init__.py deleted file mode 100644 index aa3a8399..00000000 --- a/cpex/framework/__init__.py +++ /dev/null @@ -1,192 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Plugin Framework Package. -Exposes core ContextForge plugin components: -- Context -- Manager -- Payloads -- Models -- ExternalPluginServer -""" - -# Standard -from typing import Optional - -# First-Party -from cpex.framework.base import Plugin -from cpex.framework.decorator import hook -from cpex.framework.errors import PluginError, PluginViolationError -from cpex.framework.external.mcp.server import ExternalPluginServer -from cpex.framework.hooks.agents import ( - AgentHookType, - AgentPostInvokePayload, - AgentPostInvokeResult, - AgentPreInvokePayload, - AgentPreInvokeResult, -) -from cpex.framework.hooks.http import ( - HttpAuthCheckPermissionPayload, - HttpAuthCheckPermissionResult, - HttpAuthCheckPermissionResultPayload, - HttpAuthResolveUserPayload, - HttpAuthResolveUserResult, - HttpHeaderPayload, - HttpHookType, - HttpPostRequestPayload, - HttpPostRequestResult, - HttpPreRequestPayload, - HttpPreRequestResult, -) -from cpex.framework.hooks.policies import HookPayloadPolicy -from cpex.framework.hooks.prompts import ( - PromptHookType, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, -) -from cpex.framework.hooks.registry import HookRegistry, get_hook_registry -from cpex.framework.hooks.resources import ( - ResourceHookType, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, -) -from cpex.framework.hooks.tools import ( - ToolHookType, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) -from cpex.framework.loader.config import ConfigLoader -from cpex.framework.loader.plugin import PluginLoader -from cpex.framework.manager import PluginManager, TenantPluginManager -from cpex.framework.models import ( - GlobalContext, - MCPClientConfig, - MCPServerConfig, - OnError, - PluginCondition, - PluginConfig, - PluginContext, - PluginContextTable, - PluginErrorModel, - PluginMode, - PluginPayload, - PluginResult, - PluginViolation, - TransportType, - UserContext, -) -from cpex.framework.observability import ObservabilityProvider -from cpex.framework.utils import get_attr - -# Plugin manager singleton (lazy initialization) -_plugin_manager: Optional[PluginManager] = None - - -def get_plugin_manager( - observability: Optional[ObservabilityProvider] = None, hook_policies: Optional[dict[str, HookPayloadPolicy]] = None -) -> Optional[PluginManager]: - """Get or initialize the plugin manager singleton. - - This is the public API for accessing the plugin manager from anywhere in the application. - The plugin manager is lazily initialized on first access if plugins are enabled. - - Args: - observability: Optional observability provider implementing ObservabilityProvider protocol. - hook_policies: Per-hook-type payload modification policies. - - Returns: - PluginManager instance if plugins are enabled, None otherwise. - - Examples: - >>> from cpex.framework import get_plugin_manager - >>> pm = get_plugin_manager() - >>> # Returns PluginManager if plugins are enabled, None otherwise - >>> pm is None or isinstance(pm, PluginManager) - True - """ - global _plugin_manager # pylint: disable=global-statement - if _plugin_manager is None: - # Use plugin framework's settings - from cpex.framework.settings import settings # pylint: disable=import-outside-toplevel - - if settings.enabled: - _plugin_manager = PluginManager( - settings.config_file, - timeout=settings.plugin_timeout, - observability=observability, - hook_policies=hook_policies, - ) - return _plugin_manager - - -__all__ = [ - "AgentHookType", - "AgentPostInvokePayload", - "AgentPostInvokeResult", - "AgentPreInvokePayload", - "AgentPreInvokeResult", - "ConfigLoader", - "ExternalPluginServer", - "get_attr", - "get_hook_registry", - "get_plugin_manager", - "GlobalContext", - "hook", - "HookRegistry", - "HttpAuthCheckPermissionPayload", - "HttpAuthCheckPermissionResult", - "HttpAuthCheckPermissionResultPayload", - "HttpAuthResolveUserPayload", - "HttpAuthResolveUserResult", - "HttpHeaderPayload", - "HttpHookType", - "HttpPostRequestPayload", - "HttpPostRequestResult", - "HttpPreRequestPayload", - "HttpPreRequestResult", - "MCPClientConfig", - "MCPServerConfig", - "ObservabilityProvider", - "OnError", - "Plugin", - "PluginCondition", - "PluginConfig", - "PluginContext", - "PluginContextTable", - "PluginError", - "PluginErrorModel", - "PluginLoader", - "PluginManager", - "PluginMode", - "PluginPayload", - "PluginResult", - "PluginViolation", - "PluginViolationError", - "PromptHookType", - "PromptPosthookPayload", - "PromptPosthookResult", - "PromptPrehookPayload", - "PromptPrehookResult", - "ResourceHookType", - "ResourcePostFetchPayload", - "ResourcePostFetchResult", - "ResourcePreFetchPayload", - "ResourcePreFetchResult", - "ToolHookType", - "ToolPostInvokePayload", - "ToolPostInvokeResult", - "ToolPreInvokeResult", - "TenantPluginManager", - "ToolPreInvokePayload", - "TransportType", - "UserContext", -] diff --git a/cpex/framework/base.py b/cpex/framework/base.py deleted file mode 100644 index 6f120141..00000000 --- a/cpex/framework/base.py +++ /dev/null @@ -1,659 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/base.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Mihai Criveti - -Base plugin implementation. -This module implements the base plugin object. -""" - -# Standard -import uuid -from abc import ABC -from typing import Awaitable, Callable, Optional, Union - -# First-Party -from cpex.framework.errors import PluginError -from cpex.framework.models import ( - OnError, - PluginCondition, - PluginConfig, - PluginContext, - PluginErrorModel, - PluginMode, - PluginPayload, - PluginResult, -) - -# pylint: disable=import-outside-toplevel - - -class Plugin(ABC): - """Base plugin object for pre/post processing of inputs and outputs at various locations throughout the server. - - Examples: - >>> from cpex.framework import PluginConfig, PluginMode - >>> from cpex.framework.hooks.prompts import PromptHookType - >>> config = PluginConfig( - ... name="test_plugin", - ... description="Test plugin", - ... author="test", - ... kind="cpex.framework.Plugin", - ... version="1.0.0", - ... hooks=[PromptHookType.PROMPT_PRE_FETCH], - ... tags=["test"], - ... mode=PluginMode.CONCURRENT, - ... priority=50 - ... ) - >>> plugin = Plugin(config) - >>> plugin.name - 'test_plugin' - >>> plugin.priority - 50 - >>> plugin.mode - - >>> PromptHookType.PROMPT_PRE_FETCH in plugin.hooks - True - """ - - def __init__( - self, - config: PluginConfig, - hook_payloads: Optional[dict[str, PluginPayload]] = None, - hook_results: Optional[dict[str, PluginResult]] = None, - ) -> None: - """Initialize a plugin with a configuration and context. - - The plugin receives the config directly. When the plugin is - registered with the Manager, the PluginRef retains the - authoritative config and gives the plugin a defensive copy, - so the Manager never trusts config read back from the plugin. - - Args: - config: The plugin configuration - hook_payloads: optional mapping of hookpoints to payloads for the plugin. - Used for external plugins for converting json to pydantic. - hook_results: optional mapping of hookpoints to result types for the plugin. - Used for external plugins for converting json to pydantic. - - Examples: - >>> from cpex.framework import PluginConfig - >>> from cpex.framework.hooks.prompts import PromptHookType - >>> config = PluginConfig( - ... name="simple_plugin", - ... description="Simple test", - ... author="test", - ... kind="test.Plugin", - ... version="1.0.0", - ... hooks=[PromptHookType.PROMPT_POST_FETCH], - ... tags=["simple"] - ... ) - >>> plugin = Plugin(config) - >>> plugin._config.name - 'simple_plugin' - """ - self._config = config - self._hook_payloads = hook_payloads - self._hook_results = hook_results - - @property - def priority(self) -> int: - """Return the plugin's priority. - - Returns: - Plugin's priority. - """ - return self._config.priority - - @property - def config(self) -> PluginConfig: - """Return the plugin's configuration. - - Returns: - Plugin's configuration. - """ - return self._config - - @property - def mode(self) -> PluginMode: - """Return the plugin's mode. - - Returns: - Plugin's mode. - """ - return self._config.mode - - @property - def name(self) -> str: - """Return the plugin's name. - - Returns: - Plugin's name. - """ - return self._config.name - - @property - def hooks(self) -> list[str]: - """Return the plugin's currently configured hooks. - - Returns: - Plugin's configured hooks. - """ - return self._config.hooks - - @property - def tags(self) -> list[str]: - """Return the plugin's tags. - - Returns: - Plugin's tags. - """ - return self._config.tags - - @property - def conditions(self) -> list[PluginCondition] | None: - """Return the plugin's conditions for operation. - - Returns: - Plugin's conditions for executing. - """ - return self._config.conditions - - async def initialize(self) -> None: - """Initialize the plugin.""" - - async def shutdown(self) -> None: - """Plugin cleanup code.""" - - def json_to_payload(self, hook: str, payload: Union[str | dict]) -> PluginPayload: - """Converts a json payload to the proper pydantic payload object given a hook type. Used - mainly for serialization/deserialization of external plugin payloads. - - Args: - hook: the hook type for which the payload needs converting. - payload: the payload as a string or dict. - - Returns: - A pydantic payload object corresponding to the hook type. - - Raises: - PluginError: if no payload type is defined. - """ - hook_payload_type: type[PluginPayload] | None = None - - # First try instance-level hook_payloads - if self._hook_payloads: - hook_payload_type = self._hook_payloads.get(hook, None) # type: ignore[assignment] - - # Fall back to global registry - if not hook_payload_type: - # First-Party - from cpex.framework.hooks.registry import get_hook_registry - - registry = get_hook_registry() - hook_payload_type = registry.get_payload_type(hook) - - if not hook_payload_type: - raise PluginError( - error=PluginErrorModel(message=f"No payload defined for hook {hook}.", plugin_name=self.name) - ) - - if isinstance(payload, str): - return hook_payload_type.model_validate_json(payload) - return hook_payload_type.model_validate(payload) - - def json_to_result(self, hook: str, result: Union[str | dict]) -> PluginResult: - """Converts a json result to the proper pydantic result object given a hook type. Used - mainly for serialization/deserialization of external plugin results. - - Args: - hook: the hook type for which the result needs converting. - result: the result as a string or dict. - - Returns: - A pydantic result object corresponding to the hook type. - - Raises: - PluginError: if no result type is defined. - """ - hook_result_type: type[PluginResult] | None = None - - # First try instance-level hook_results - if self._hook_results: - hook_result_type = self._hook_results.get(hook, None) # type: ignore[assignment] - - # Fall back to global registry - if not hook_result_type: - # First-Party - from cpex.framework.hooks.registry import get_hook_registry - - registry = get_hook_registry() - hook_result_type = registry.get_result_type(hook) - - if not hook_result_type: - raise PluginError( - error=PluginErrorModel(message=f"No result defined for hook {hook}.", plugin_name=self.name) - ) - - if isinstance(result, str): - return hook_result_type.model_validate_json(result) - return hook_result_type.model_validate(result) - - -class PluginRef: - """Plugin reference which contains a uuid. - - Examples: - >>> from cpex.framework import PluginConfig, PluginMode - >>> from cpex.framework.hooks.prompts import PromptHookType - >>> config = PluginConfig( - ... name="ref_test", - ... description="Reference test", - ... author="test", - ... kind="test.Plugin", - ... version="1.0.0", - ... hooks=[PromptHookType.PROMPT_PRE_FETCH], - ... tags=["ref", "test"], - ... mode=PluginMode.AUDIT, - ... priority=100 - ... ) - >>> plugin = Plugin(config) - >>> ref = PluginRef(plugin) - >>> ref.name - 'ref_test' - >>> ref.priority - 100 - >>> ref.mode - - >>> len(ref.uuid) # UUID is a 32-character hex string - 32 - >>> ref.tags - ['ref', 'test'] - """ - - def __init__(self, plugin: Plugin, trusted_config: PluginConfig | None = None): - """Initialize a plugin reference. - - Stores the authoritative config separately from the plugin. - The Manager reads policy-sensitive fields (capabilities, mode, - on_error) from the trusted config, never from the plugin. - - Args: - plugin: The plugin to reference. - trusted_config: The authoritative config retained by the - Manager. If not provided, falls back to plugin.config - (for backward compatibility in tests). - - Examples: - >>> from cpex.framework import PluginConfig - >>> from cpex.framework.hooks.prompts import PromptHookType - >>> config = PluginConfig( - ... name="plugin_ref", - ... description="Test", - ... author="test", - ... kind="test.Plugin", - ... version="1.0.0", - ... hooks=[PromptHookType.PROMPT_POST_FETCH], - ... tags=[] - ... ) - >>> plugin = Plugin(config) - >>> ref = PluginRef(plugin) - >>> ref._plugin.name - 'plugin_ref' - >>> isinstance(ref._uuid, uuid.UUID) - True - """ - self._plugin = plugin - self._trusted_config = trusted_config or plugin.config - self._uuid = uuid.uuid4() - - @property - def plugin(self) -> Plugin: - """Return the underlying plugin. - - Returns: - The underlying plugin. - """ - return self._plugin - - @property - def trusted_config(self) -> PluginConfig: - """Return the authoritative config held by the Manager. - - Returns: - The trusted PluginConfig (not the plugin's copy). - """ - return self._trusted_config - - @property - def uuid(self) -> str: - """Return the plugin's UUID. - - Returns: - Plugin's UUID. - """ - return self._uuid.hex - - @property - def priority(self) -> int: - """Returns the plugin's priority. - - Returns: - Plugin's priority. - """ - return self._trusted_config.priority - - @property - def name(self) -> str: - """Return the plugin's name. - - Returns: - Plugin's name. - """ - return self._trusted_config.name - - @property - def hooks(self) -> list[str]: - """Returns the plugin's currently configured hooks. - - Returns: - Plugin's configured hooks. - """ - return self._trusted_config.hooks - - @property - def tags(self) -> list[str]: - """Return the plugin's tags. - - Returns: - Plugin's tags. - """ - return self._trusted_config.tags - - @property - def conditions(self) -> list[PluginCondition] | None: - """Return the plugin's conditions for operation. - - Returns: - Plugin's conditions for operation. - """ - return self._trusted_config.conditions - - @property - def mode(self) -> PluginMode: - """Return the plugin's mode. - - Returns: - Plugin's mode. - """ - return self._trusted_config.mode - - @property - def on_error(self) -> OnError: - """Return the plugin's on_error behavior. - - Returns: - Plugin's on_error behavior. - """ - return self._trusted_config.on_error - - @property - def capabilities(self) -> frozenset[str]: - """Return the plugin's declared capabilities. - - Returns: - The authoritative capability set from the trusted config. - """ - return self._trusted_config.capabilities - - -class HookRef: - """A Hook reference point with plugin and function.""" - - def __init__(self, hook: str, plugin_ref: PluginRef): - """Initialize a hook reference point. - - Discovers the hook method using either: - 1. Convention-based naming (method name matches hook type) - 2. Decorator-based (@hook decorator with matching hook_type) - - Args: - hook: name of the hook point (e.g., 'tool_pre_invoke'). - plugin_ref: The reference to the plugin to hook. - - Raises: - PluginError: If no method is found for the specified hook. - - Examples: - >>> from cpex.framework import PluginConfig - >>> config = PluginConfig(name="test", kind="test", version="1.0", author="test", hooks=["tool_pre_invoke"]) - >>> plugin = Plugin(config) - >>> plugin_ref = PluginRef(plugin) - >>> # This would work if plugin has tool_pre_invoke method or @hook("tool_pre_invoke") decorator - """ - # Standard - import inspect - - # First-Party - from cpex.framework.decorator import get_hook_metadata - - self._plugin_ref = plugin_ref - self._hook = hook - - # Try convention-based lookup first (method name matches hook type) - self._func: Callable[[PluginPayload, PluginContext], Awaitable[PluginResult]] | None = getattr( - plugin_ref.plugin, hook, None - ) - - # If not found by convention, scan for @hook decorated methods - if self._func is None: - for name, method in inspect.getmembers(plugin_ref.plugin, predicate=inspect.ismethod): - # Skip private/magic methods - if name.startswith("_"): - continue - - # Check for @hook decorator metadata - metadata = get_hook_metadata(method) - if metadata and metadata.matches(hook): - self._func = method - break - - # Raise error if hook method not found by either approach - if not self._func: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_ref.plugin.name}' has no hook: '{hook}'. " - f"Method must either be named '{hook}' or decorated with @hook('{hook}')", - plugin_name=plugin_ref.plugin.name, - ) - ) - - # Validate hook method signature (parameter count and async) - param_count = self._validate_hook_signature(hook, self._func, plugin_ref.plugin.name) - - # Store whether the plugin accepts extensions as a third argument - self._accepts_extensions = param_count == 3 - - def _validate_hook_signature(self, hook: str, func: Callable, plugin_name: str) -> int: - """Validate that the hook method has the correct signature. - - Checks: - 1. Method accepts 2 parameters (payload, context) or 3 (payload, context, extensions) - 2. Method is async (returns coroutine) - - Args: - hook: The hook type being validated - func: The hook method to validate - plugin_name: Name of the plugin (for error messages) - - Returns: - The number of parameters (2 or 3). - - Raises: - PluginError: If the signature is invalid - """ - # Standard - import inspect - - sig = inspect.signature(func) - params = list(sig.parameters.values()) - - # Check parameter count (should be: payload, context[, extensions]) - # Note: 'self' is not included in bound method signatures - if len(params) not in (2, 3): - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_name}' hook '{hook}' has invalid signature. " - f"Expected 2 or 3 parameters (payload, context[, extensions]), " - f"got {len(params)}: {list(sig.parameters.keys())}. " - f"Correct signature: async def {hook}(self, payload: PayloadType, " - f"context: PluginContext[, extensions: Extensions]) -> ResultType", - plugin_name=plugin_name, - ) - ) - - # Check that method is async - if not inspect.iscoroutinefunction(func): - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_name}' hook '{hook}' must be async. " - f"Method '{func.__name__}' is not a coroutine function. " - f"Use 'async def {func.__name__}(...)' instead of 'def {func.__name__}(...)'.", - plugin_name=plugin_name, - ) - ) - - return len(params) - - def _validate_type_hints(self, hook: str, func: Callable, params: list, plugin_name: str) -> None: - """Validate that type hints match expected payload and result types. - - This is an optional validation that can be enabled to enforce type safety. - - Args: - hook: The hook type being validated - func: The hook method to validate - params: List of function parameters - plugin_name: Name of the plugin (for error messages) - - Raises: - PluginError: If type hints are missing or don't match expected types - """ - # Standard - from typing import get_type_hints - - # First-Party - from cpex.framework.hooks.registry import get_hook_registry - - # Get expected types from registry - registry = get_hook_registry() - expected_payload_type = registry.get_payload_type(hook) - expected_result_type = registry.get_result_type(hook) - - # If hook is not registered in global registry, we can't validate types - if not expected_payload_type or not expected_result_type: - return - - # Get type hints from the function - try: - hints = get_type_hints(func) - except Exception as e: - # Type hints might use forward references or unavailable types - # We'll skip validation rather than fail - # Standard - import logging - - logger = logging.getLogger(__name__) - logger.debug("Could not extract type hints for plugin '%s' hook '%s': %s", plugin_name, hook, e) - return - - # Validate payload parameter type (first parameter, since 'self' is not in params) - payload_param_name = params[0].name - if payload_param_name not in hints: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_name}' hook '{hook}' missing type hint for parameter '{payload_param_name}'. " - f"Expected: {payload_param_name}: {expected_payload_type.__name__}", - plugin_name=plugin_name, - ) - ) - - actual_payload_type = hints[payload_param_name] - - # Check if types match (exact match or subclass) - if actual_payload_type != expected_payload_type: - # Check for generic types or complex type hints - actual_type_str = str(actual_payload_type) - expected_type_str = expected_payload_type.__name__ - - # If the expected type name is in the string representation, it's probably OK - if expected_type_str not in actual_type_str: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_name}' hook '{hook}' parameter '{payload_param_name}' " - f"has incorrect type hint. Expected: {expected_type_str}, Got: {actual_type_str}", - plugin_name=plugin_name, - ) - ) - - # Validate return type - if "return" not in hints: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_name}' hook '{hook}' missing return type hint. " - f"Expected: -> {expected_result_type.__name__}", - plugin_name=plugin_name, - ) - ) - - actual_return_type = hints["return"] - return_type_str = str(actual_return_type) - expected_return_str = expected_result_type.__name__ - - # For async functions, the return type might be wrapped in Coroutine or Awaitable - # We just check if the expected type is mentioned in the return type - if expected_return_str not in return_type_str and actual_return_type != expected_result_type: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin '{plugin_name}' hook '{hook}' has incorrect return type hint. " - f"Expected: {expected_return_str}, Got: {return_type_str}", - plugin_name=plugin_name, - ) - ) - - @property - def plugin_ref(self) -> PluginRef: - """The reference to the plugin object. - - Returns: - A plugin reference. - """ - return self._plugin_ref - - @property - def name(self) -> str: - """The name of the hooking function. - - Returns: - A plugin name. - """ - return self._hook - - @property - def accepts_extensions(self) -> bool: - """Whether the hook method accepts extensions as a third argument. - - Returns: - True if the hook signature has 3 parameters (payload, context, extensions). - """ - return self._accepts_extensions - - @property - def hook(self) -> Callable[..., Awaitable[PluginResult]] | None: - """The hooking function that can be invoked within the reference. - - Returns: - An awaitable hook function reference. - """ - return self._func diff --git a/cpex/framework/cmf/__init__.py b/cpex/framework/cmf/__init__.py deleted file mode 100644 index f446938e..00000000 --- a/cpex/framework/cmf/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/cmf/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Common Message Format (CMF) Package. -Provides the canonical, provider-agnostic message representation -for interactions between users, agents, tools, and language models. -""" diff --git a/cpex/framework/cmf/message.py b/cpex/framework/cmf/message.py deleted file mode 100644 index 2078bfda..00000000 --- a/cpex/framework/cmf/message.py +++ /dev/null @@ -1,941 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/cmf/message.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Common Message Format (CMF) message models. -This module implements the canonical message representation for interactions -between users, agents, tools, and language models. All models are frozen -(immutable) and require model_copy() for modification, supporting the CMF's -copy-on-write semantics and mutability tier enforcement. - -Domain objects (ToolCall, ImageSource, etc.) are standalone frozen models -reusable across contexts. ContentPart wrappers (ToolCallContentPart, etc.) -compose them into the typed content-part hierarchy for message serialization. -""" - -# Standard -from __future__ import annotations - -from enum import Enum -from typing import TYPE_CHECKING, Annotated, Any, Iterator, Literal, Union - -# Third-Party -from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag, model_validator - -# First-Party -from cpex.framework.extensions.extensions import Extensions - -# --------------------------------------------------------------------------- -# Enums -# --------------------------------------------------------------------------- - - -class Role(str, Enum): - """Closed-set enumeration of message roles. - - Identifies WHO is speaking in a conversation turn. - - Attributes: - SYSTEM: System-level instructions. - DEVELOPER: Developer-provided instructions. - USER: Human user input. - ASSISTANT: LLM/agent response. - TOOL: Tool execution result. - - Examples: - >>> Role.USER - - >>> Role.USER.value - 'user' - >>> Role("assistant") - - """ - - SYSTEM = "system" - DEVELOPER = "developer" - USER = "user" - ASSISTANT = "assistant" - TOOL = "tool" - - -class Channel(str, Enum): - """Closed-set enumeration of output channel types. - - Classifies the kind of output a message represents, allowing - pipelines to route or filter messages by output type without - inspecting content. - - Attributes: - ANALYSIS: Intermediate analytical output not intended as final response. - COMMENTARY: Meta-level observations about the task or process. - FINAL: Terminal response intended for delivery to the end consumer. - - Examples: - >>> Channel.FINAL - - >>> Channel("analysis") - - """ - - ANALYSIS = "analysis" - COMMENTARY = "commentary" - FINAL = "final" - - -class ContentType(str, Enum): - """Closed-set enumeration of content part types. - - Discriminator for the typed ContentPart hierarchy, identifying - the kind of content carried by each part of a multimodal message. - - Attributes: - TEXT: Plain text content. - THINKING: Chain-of-thought reasoning. - TOOL_CALL: Tool/function invocation request. - TOOL_RESULT: Result from tool execution. - RESOURCE: Embedded resource with content (MCP). - RESOURCE_REF: Lightweight resource reference without embedded content. - PROMPT_REQUEST: Prompt template invocation request (MCP). - PROMPT_RESULT: Rendered prompt template result. - IMAGE: Image content (URL or base64). - VIDEO: Video content (URL or base64). - AUDIO: Audio content (URL or base64). - DOCUMENT: Document content (PDF, Word, etc.). - - Examples: - >>> ContentType.TOOL_CALL - - >>> ContentType("text") - - """ - - TEXT = "text" - THINKING = "thinking" - TOOL_CALL = "tool_call" - TOOL_RESULT = "tool_result" - RESOURCE = "resource" - RESOURCE_REF = "resource_ref" - PROMPT_REQUEST = "prompt_request" - PROMPT_RESULT = "prompt_result" - IMAGE = "image" - VIDEO = "video" - AUDIO = "audio" - DOCUMENT = "document" - - -class ResourceType(str, Enum): - """Closed-set enumeration of resource types. - - Attributes: - FILE: File-system resource. - BLOB: Binary large object. - URI: Generic URI-addressable resource. - DATABASE: Database entity. - API: API endpoint. - MEMORY: In-memory or ephemeral resource. - ARTIFACT: Produced artifact (generated output, build result). - - Examples: - >>> ResourceType.FILE - - >>> ResourceType("database") - - """ - - FILE = "file" - BLOB = "blob" - URI = "uri" - DATABASE = "database" - API = "api" - MEMORY = "memory" - ARTIFACT = "artifact" - - -# --------------------------------------------------------------------------- -# Domain Objects (standalone, reusable across contexts) -# --------------------------------------------------------------------------- - - -class ToolCall(BaseModel): - """Normalized tool/function invocation request. - - Standalone domain object reusable outside of message content parts. - - Attributes: - tool_call_id: Unique request correlation ID. - name: Tool name. - arguments: Arguments as a JSON-serializable dict. - namespace: Optional namespace for namespaced tools. - - Examples: - >>> call = ToolCall( - ... tool_call_id="tc_001", - ... name="get_user", - ... arguments={"user_id": "123"}, - ... ) - >>> call.name - 'get_user' - >>> call.arguments - {'user_id': '123'} - """ - - model_config = ConfigDict(frozen=True) - - tool_call_id: str = Field(description="Unique request correlation ID.") - name: str = Field(description="Tool name.") - arguments: dict[str, Any] = Field(default_factory=dict, description="Arguments as a JSON-serializable dict.") - namespace: str | None = Field(default=None, description="Namespace for namespaced tools.") - - -class ToolResult(BaseModel): - """Result from tool execution. - - Standalone domain object reusable outside of message content parts. - - Attributes: - tool_call_id: Correlation ID linking to the corresponding tool call. - tool_name: Name of the tool that was executed. - content: Result content, any JSON-serializable value. - is_error: Whether the result represents an error. - - Examples: - >>> result = ToolResult( - ... tool_call_id="tc_001", - ... tool_name="get_user", - ... content={"name": "Alice"}, - ... ) - >>> result.is_error - False - >>> result.tool_name - 'get_user' - """ - - model_config = ConfigDict(frozen=True) - - tool_call_id: str = Field(description="Correlation ID linking to the corresponding tool call.") - tool_name: str = Field(description="Name of the tool that was executed.") - content: Any = Field(default=None, description="Result content, any JSON-serializable value.") - is_error: bool = Field(default=False, description="Whether the result represents an error.") - - -class Resource(BaseModel): - """Embedded resource with content (MCP). - - Standalone domain object reusable outside of message content parts. - - Attributes: - resource_request_id: Unique request correlation ID. - uri: Unique identifier in URI format. - name: Human-readable name. - description: What this resource contains. - resource_type: The kind of resource. - content: Text content if embedded. - blob: Binary content if embedded. - mime_type: MIME type of content. - size_bytes: Size information. - annotations: Metadata (classification, retention, etc.). - version: Version tracking. - - Examples: - >>> res = Resource( - ... resource_request_id="rr_001", - ... uri="file:///data/report.csv", - ... name="Q4 Report", - ... resource_type=ResourceType.FILE, - ... content="col1,col2\\n1,2", - ... mime_type="text/csv", - ... ) - >>> res.uri - 'file:///data/report.csv' - """ - - model_config = ConfigDict(frozen=True) - - resource_request_id: str = Field(description="Unique request correlation ID.") - uri: str = Field(description="Unique identifier in URI format.") - name: str | None = Field(default=None, description="Human-readable name.") - description: str | None = Field(default=None, description="What this resource contains.") - resource_type: ResourceType = Field(description="The kind of resource.") - content: str | None = Field(default=None, description="Text content if embedded.") - blob: bytes | None = Field(default=None, description="Binary content if embedded.") - - @model_validator(mode="after") - def _check_content_blob_exclusion(self) -> Resource: - """Ensure content and blob are mutually exclusive. - - Returns: - The validated Resource instance. - - Raises: - ValueError: If both content and blob are set. - """ - if self.content is not None and self.blob is not None: - raise ValueError("Resource cannot have both 'content' and 'blob' set") - return self - - mime_type: str | None = Field(default=None, description="MIME type of content.") - size_bytes: int | None = Field(default=None, description="Size information.") - annotations: dict[str, Any] = Field(default_factory=dict, description="Metadata (classification, retention, etc.).") - version: str | None = Field(default=None, description="Version tracking.") - - -class ResourceReference(BaseModel): - """Lightweight resource reference without embedded content. - - Standalone domain object reusable outside of message content parts. - - Attributes: - resource_request_id: Correlation ID linking to the originating resource request. - uri: Resource URI. - name: Human-readable name. - resource_type: Type of resource. - range_start: Line number or byte offset for partial references. - range_end: End of range. - selector: CSS/XPath/JSONPath selector. - - Examples: - >>> ref = ResourceReference( - ... resource_request_id="rr_002", - ... uri="db://users/42", - ... resource_type=ResourceType.DATABASE, - ... ) - >>> ref.uri - 'db://users/42' - """ - - model_config = ConfigDict(frozen=True) - - resource_request_id: str = Field(description="Correlation ID linking to the originating resource request.") - uri: str = Field(description="Resource URI.") - name: str | None = Field(default=None, description="Human-readable name.") - resource_type: ResourceType = Field(description="Type of resource.") - range_start: int | None = Field(default=None, description="Line number or byte offset for partial references.") - range_end: int | None = Field(default=None, description="End of range.") - selector: str | None = Field(default=None, description="CSS/XPath/JSONPath selector.") - - @model_validator(mode="after") - def _check_range_consistency(self) -> ResourceReference: - """Ensure range_end is not less than range_start. - - Returns: - The validated ResourceReference instance. - - Raises: - ValueError: If range_end < range_start. - """ - if self.range_start is not None and self.range_end is not None: - if self.range_end < self.range_start: - raise ValueError(f"range_end ({self.range_end}) must be >= range_start ({self.range_start})") - return self - - -class PromptRequest(BaseModel): - """Prompt template invocation request (MCP). - - Standalone domain object reusable outside of message content parts. - - Attributes: - prompt_request_id: Request ID for correlation. - name: Prompt template name. - arguments: Arguments to pass to the template. - server_id: Source server for multi-server scenarios. - - Examples: - >>> req = PromptRequest( - ... prompt_request_id="pr_001", - ... name="summarize", - ... arguments={"text": "Long document..."}, - ... ) - >>> req.name - 'summarize' - """ - - model_config = ConfigDict(frozen=True) - - prompt_request_id: str = Field(description="Request ID for correlation.") - name: str = Field(description="Prompt template name.") - arguments: dict[str, Any] = Field(default_factory=dict, description="Arguments to pass to the template.") - server_id: str | None = Field(default=None, description="Source server for multi-server scenarios.") - - -class PromptResult(BaseModel): - """Rendered prompt template result. - - Standalone domain object reusable outside of message content parts. - - Attributes: - prompt_request_id: ID of the corresponding prompt request. - prompt_name: Name of the prompt that was rendered. - messages: Rendered messages (prompts produce messages). - content: Single text result for simple prompts. - is_error: Whether rendering failed. - error_message: Error details if rendering failed. - - Examples: - >>> result = PromptResult( - ... prompt_request_id="pr_001", - ... prompt_name="summarize", - ... content="This document discusses...", - ... ) - >>> result.is_error - False - """ - - model_config = ConfigDict(frozen=True) - - prompt_request_id: str = Field(description="ID of the corresponding prompt request.") - prompt_name: str = Field(description="Name of the prompt that was rendered.") - messages: list[Message] = Field( - default_factory=list, - description="Rendered messages (prompts produce messages).", - ) - content: str | None = Field(default=None, description="Single text result for simple prompts.") - is_error: bool = Field(default=False, description="Whether rendering failed.") - error_message: str | None = Field(default=None, description="Error details if rendering failed.") - - -class ImageSource(BaseModel): - """Image source data. - - Standalone domain object reusable outside of message content parts. - - Attributes: - type: Source type, either URL or base64-encoded. - data: URL or base64-encoded string. - media_type: MIME type (e.g., image/jpeg). - - Examples: - >>> img = ImageSource(type="url", data="https://example.com/photo.jpg") - >>> img.type - 'url' - """ - - model_config = ConfigDict(frozen=True) - - type: Literal["url", "base64"] = Field(description="Source type: 'url' or 'base64'.") - data: str = Field(description="URL or base64-encoded string.") - media_type: str | None = Field(default=None, description="MIME type (e.g., image/jpeg).") - - -class VideoSource(BaseModel): - """Video source data. - - Standalone domain object reusable outside of message content parts. - - Attributes: - type: Source type, either URL or base64-encoded. - data: URL or base64-encoded string. - media_type: MIME type (e.g., video/mp4). - duration_ms: Duration in milliseconds. - - Examples: - >>> vid = VideoSource(type="url", data="https://example.com/clip.mp4") - >>> vid.type - 'url' - """ - - model_config = ConfigDict(frozen=True) - - type: Literal["url", "base64"] = Field(description="Source type: 'url' or 'base64'.") - data: str = Field(description="URL or base64-encoded string.") - media_type: str | None = Field(default=None, description="MIME type (e.g., video/mp4).") - duration_ms: int | None = Field(default=None, description="Duration in milliseconds.") - - -class AudioSource(BaseModel): - """Audio source data. - - Standalone domain object reusable outside of message content parts. - - Attributes: - type: Source type, either URL or base64-encoded. - data: URL or base64-encoded string. - media_type: MIME type (e.g., audio/mp3). - duration_ms: Duration in milliseconds. - - Examples: - >>> aud = AudioSource(type="url", data="https://example.com/track.mp3") - >>> aud.type - 'url' - """ - - model_config = ConfigDict(frozen=True) - - type: Literal["url", "base64"] = Field(description="Source type: 'url' or 'base64'.") - data: str = Field(description="URL or base64-encoded string.") - media_type: str | None = Field(default=None, description="MIME type (e.g., audio/mp3).") - duration_ms: int | None = Field(default=None, description="Duration in milliseconds.") - - -class DocumentSource(BaseModel): - """Document source data (PDF, Word, etc.). - - Standalone domain object reusable outside of message content parts. - - Attributes: - type: Source type, either URL or base64-encoded. - data: URL or base64-encoded string. - media_type: MIME type (e.g., application/pdf). - title: Document title. - - Examples: - >>> doc = DocumentSource( - ... type="base64", - ... data="JVBERi0xLjQ...", - ... media_type="application/pdf", - ... title="Annual Report", - ... ) - >>> doc.title - 'Annual Report' - """ - - model_config = ConfigDict(frozen=True) - - type: Literal["url", "base64"] = Field(description="Source type: 'url' or 'base64'.") - data: str = Field(description="URL or base64-encoded string.") - media_type: str | None = Field(default=None, description="MIME type (e.g., application/pdf).") - title: str | None = Field(default=None, description="Document title.") - - -# --------------------------------------------------------------------------- -# Content Parts (ContentPart base + wrappers) -# --------------------------------------------------------------------------- - - -class ContentPart(BaseModel): - """Base class for all content parts in a CMF message. - - Frozen by design — subclasses inherit immutability. Consumers must - use model_copy(update={...}) to create modified copies. - - Attributes: - content_type: Discriminator identifying the concrete content type. - - Examples: - >>> part = TextContent(text="hello") - >>> isinstance(part, ContentPart) - True - >>> part.content_type - - """ - - model_config = ConfigDict(frozen=True) - - content_type: ContentType = Field(description="Content type discriminator.") - - -class TextContent(ContentPart): - """Plain text content part. - - Attributes: - content_type: Discriminator, always ContentType.TEXT. - text: The text content. - - Examples: - >>> part = TextContent(text="Hello, world!") - >>> part.content_type - - >>> part.text - 'Hello, world!' - >>> modified = part.model_copy(update={"text": "Updated"}) - >>> (part.text, modified.text) - ('Hello, world!', 'Updated') - """ - - content_type: Literal[ContentType.TEXT] = Field(default=ContentType.TEXT, description="Content type discriminator.") - text: str = Field(description="The text content.") - - -class ThinkingContent(ContentPart): - """Chain-of-thought reasoning content part. - - Attributes: - content_type: Discriminator, always ContentType.THINKING. - text: The reasoning text. - - Examples: - >>> part = ThinkingContent(text="Let me analyze this...") - >>> part.content_type - - """ - - content_type: Literal[ContentType.THINKING] = Field( - default=ContentType.THINKING, description="Content type discriminator." - ) - text: str = Field(description="The reasoning text.") - - -class ToolCallContentPart(ContentPart): - """Content part wrapping a ToolCall domain object. - - Attributes: - content_type: Discriminator, always ContentType.TOOL_CALL. - content: The wrapped ToolCall. - - Examples: - >>> part = ToolCallContentPart( - ... content=ToolCall(tool_call_id="tc_001", name="search", arguments={"q": "test"}), - ... ) - >>> part.content.name - 'search' - """ - - content_type: Literal[ContentType.TOOL_CALL] = Field( - default=ContentType.TOOL_CALL, description="Content type discriminator." - ) - content: ToolCall = Field(description="The wrapped ToolCall.") - - -class ToolResultContentPart(ContentPart): - """Content part wrapping a ToolResult domain object. - - Attributes: - content_type: Discriminator, always ContentType.TOOL_RESULT. - content: The wrapped ToolResult. - - Examples: - >>> part = ToolResultContentPart( - ... content=ToolResult(tool_call_id="tc_001", tool_name="search", content="Found 10 results"), - ... ) - >>> part.content.tool_name - 'search' - """ - - content_type: Literal[ContentType.TOOL_RESULT] = Field( - default=ContentType.TOOL_RESULT, description="Content type discriminator." - ) - content: ToolResult = Field(description="The wrapped ToolResult.") - - -class ResourceContentPart(ContentPart): - """Content part wrapping a Resource domain object. - - Attributes: - content_type: Discriminator, always ContentType.RESOURCE. - content: The wrapped Resource. - - Examples: - >>> part = ResourceContentPart( - ... content=Resource(resource_request_id="rr_001", uri="file:///data.txt", resource_type=ResourceType.FILE), - ... ) - >>> part.content.uri - 'file:///data.txt' - """ - - content_type: Literal[ContentType.RESOURCE] = Field( - default=ContentType.RESOURCE, description="Content type discriminator." - ) - content: Resource = Field(description="The wrapped Resource.") - - -class ResourceRefContentPart(ContentPart): - """Content part wrapping a ResourceReference domain object. - - Attributes: - content_type: Discriminator, always ContentType.RESOURCE_REF. - content: The wrapped ResourceReference. - - Examples: - >>> part = ResourceRefContentPart( - ... content=ResourceReference(resource_request_id="rr_002", uri="db://users/42", resource_type=ResourceType.DATABASE), - ... ) - >>> part.content.uri - 'db://users/42' - """ - - content_type: Literal[ContentType.RESOURCE_REF] = Field( - default=ContentType.RESOURCE_REF, description="Content type discriminator." - ) - content: ResourceReference = Field(description="The wrapped ResourceReference.") - - -class PromptRequestContentPart(ContentPart): - """Content part wrapping a PromptRequest domain object. - - Attributes: - content_type: Discriminator, always ContentType.PROMPT_REQUEST. - content: The wrapped PromptRequest. - - Examples: - >>> part = PromptRequestContentPart( - ... content=PromptRequest(prompt_request_id="pr_001", name="summarize"), - ... ) - >>> part.content.name - 'summarize' - """ - - content_type: Literal[ContentType.PROMPT_REQUEST] = Field( - default=ContentType.PROMPT_REQUEST, description="Content type discriminator." - ) - content: PromptRequest = Field(description="The wrapped PromptRequest.") - - -class PromptResultContentPart(ContentPart): - """Content part wrapping a PromptResult domain object. - - Attributes: - content_type: Discriminator, always ContentType.PROMPT_RESULT. - content: The wrapped PromptResult. - - Examples: - >>> part = PromptResultContentPart( - ... content=PromptResult(prompt_request_id="pr_001", prompt_name="summarize"), - ... ) - >>> part.content.prompt_name - 'summarize' - """ - - content_type: Literal[ContentType.PROMPT_RESULT] = Field( - default=ContentType.PROMPT_RESULT, description="Content type discriminator." - ) - content: PromptResult = Field(description="The wrapped PromptResult.") - - -class ImageContentPart(ContentPart): - """Content part wrapping an ImageSource domain object. - - Attributes: - content_type: Discriminator, always ContentType.IMAGE. - content: The wrapped ImageSource. - - Examples: - >>> part = ImageContentPart( - ... content=ImageSource(type="url", data="https://example.com/photo.jpg"), - ... ) - >>> part.content.type - 'url' - """ - - content_type: Literal[ContentType.IMAGE] = Field( - default=ContentType.IMAGE, description="Content type discriminator." - ) - content: ImageSource = Field(description="The wrapped ImageSource.") - - -class VideoContentPart(ContentPart): - """Content part wrapping a VideoSource domain object. - - Attributes: - content_type: Discriminator, always ContentType.VIDEO. - content: The wrapped VideoSource. - - Examples: - >>> part = VideoContentPart( - ... content=VideoSource(type="url", data="https://example.com/clip.mp4"), - ... ) - >>> part.content.type - 'url' - """ - - content_type: Literal[ContentType.VIDEO] = Field( - default=ContentType.VIDEO, description="Content type discriminator." - ) - content: VideoSource = Field(description="The wrapped VideoSource.") - - -class AudioContentPart(ContentPart): - """Content part wrapping an AudioSource domain object. - - Attributes: - content_type: Discriminator, always ContentType.AUDIO. - content: The wrapped AudioSource. - - Examples: - >>> part = AudioContentPart( - ... content=AudioSource(type="url", data="https://example.com/track.mp3"), - ... ) - >>> part.content.type - 'url' - """ - - content_type: Literal[ContentType.AUDIO] = Field( - default=ContentType.AUDIO, description="Content type discriminator." - ) - content: AudioSource = Field(description="The wrapped AudioSource.") - - -class DocumentContentPart(ContentPart): - """Content part wrapping a DocumentSource domain object. - - Attributes: - content_type: Discriminator, always ContentType.DOCUMENT. - content: The wrapped DocumentSource. - - Examples: - >>> part = DocumentContentPart( - ... content=DocumentSource(type="base64", data="JVBERi0xLjQ...", media_type="application/pdf"), - ... ) - >>> part.content.media_type - 'application/pdf' - """ - - content_type: Literal[ContentType.DOCUMENT] = Field( - default=ContentType.DOCUMENT, description="Content type discriminator." - ) - content: DocumentSource = Field(description="The wrapped DocumentSource.") - - -# --------------------------------------------------------------------------- -# ContentPart Discriminated Union -# --------------------------------------------------------------------------- - - -def _content_type_discriminator(v: Any) -> str: - """Extract the content_type discriminator value from a content part. - - Supports both dict (during deserialization) and model instance access. - - Args: - v: A content part as a dict or model instance. - - Returns: - The content_type string value for discriminator routing. - """ - if isinstance(v, dict): - ct = v.get("content_type") - if ct is None: - raise ValueError("Missing 'content_type' discriminator in content part dict") - return ct - if not hasattr(v, "content_type"): - raise ValueError(f"Content part {type(v).__name__} missing 'content_type' attribute") - return v.content_type.value - - -ContentPartUnion = Annotated[ - Union[ - Annotated[TextContent, Tag("text")], - Annotated[ThinkingContent, Tag("thinking")], - Annotated[ToolCallContentPart, Tag("tool_call")], - Annotated[ToolResultContentPart, Tag("tool_result")], - Annotated[ResourceContentPart, Tag("resource")], - Annotated[ResourceRefContentPart, Tag("resource_ref")], - Annotated[PromptRequestContentPart, Tag("prompt_request")], - Annotated[PromptResultContentPart, Tag("prompt_result")], - Annotated[ImageContentPart, Tag("image")], - Annotated[VideoContentPart, Tag("video")], - Annotated[AudioContentPart, Tag("audio")], - Annotated[DocumentContentPart, Tag("document")], - ], - Discriminator(_content_type_discriminator), -] -"""Discriminated union of all content part types. - -Pydantic uses the content_type field to resolve the correct subclass -during validation and deserialization. -""" - - -# --------------------------------------------------------------------------- -# Message -# --------------------------------------------------------------------------- - - -class Message(BaseModel): - """Canonical CMF message representing a single turn in a conversation. - - A Message is the storage and wire format. It preserves the structure - exactly as the LLM or framework sent it. For policy evaluation and - inspection, use MessageView (via iter_views()) which decomposes the - message into individually addressable, uniformly accessible parts. - - All Message instances are frozen. To create a modified copy, use - model_copy(update={...}). - - Attributes: - schema_version: Message schema version. - role: Who is speaking. - content: List of typed content parts (multimodal). - channel: Optional output classification. - extensions: Optional contextual metadata (identity, security, governance). - - Examples: - >>> msg = Message( - ... role=Role.USER, - ... content=[TextContent(text="What is the weather?")], - ... ) - >>> msg.role - - >>> msg.content[0].text - 'What is the weather?' - >>> msg.schema_version - '2.0' - - >>> # Frozen: modifications require model_copy - >>> updated = msg.model_copy(update={"channel": Channel.FINAL}) - >>> updated.channel - - >>> msg.channel is None - True - - >>> # Multi-part assistant message - >>> assistant_msg = Message( - ... role=Role.ASSISTANT, - ... content=[ - ... ThinkingContent(text="I should check the weather API."), - ... TextContent(text="Let me look that up."), - ... ToolCallContentPart( - ... content=ToolCall( - ... tool_call_id="tc_001", - ... name="get_weather", - ... arguments={"city": "London"}, - ... ), - ... ), - ... ], - ... ) - >>> len(assistant_msg.content) - 3 - >>> assistant_msg.content[2].content.name - 'get_weather' - """ - - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: str = Field(default="2.0", description="Message schema version.") - role: Role = Field(description="Who is speaking.") - content: list[ContentPartUnion] = Field(default_factory=list, description="List of typed content parts.") - channel: Channel | None = Field(default=None, description="Optional output classification.") - - def iter_views(self, hook: str | None = None, extensions: Extensions | None = None) -> Iterator[MessageView]: - """Decompose this message into individually addressable MessageViews. - - Yields one MessageView per content part. Each view provides a - uniform interface for policy evaluation regardless of content type. - - Args: - hook: Optional hook location string (e.g., "llm_input", - "tool_post_invoke") to attach to each view. - - Returns: - An iterator of MessageView objects. - - Examples: - >>> msg = Message( - ... role=Role.ASSISTANT, - ... content=[ - ... TextContent(text="Let me check."), - ... ToolCallContentPart( - ... content=ToolCall( - ... tool_call_id="tc_001", - ... name="get_weather", - ... arguments={"city": "London"}, - ... ), - ... ), - ... ], - ... ) - >>> views = list(msg.iter_views()) - >>> len(views) - 2 - >>> views[0].kind.value - 'text' - >>> views[1].name - 'get_weather' - """ - from cpex.framework.cmf.view import iter_views # pylint: disable=import-outside-toplevel - - return iter_views(self, hook=hook, extensions=extensions) - - -if TYPE_CHECKING: - from cpex.framework.cmf.view import MessageView diff --git a/cpex/framework/cmf/view.py b/cpex/framework/cmf/view.py deleted file mode 100644 index 163fb79e..00000000 --- a/cpex/framework/cmf/view.py +++ /dev/null @@ -1,1230 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/cmf/view.py -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 accessing the underlying -content part and message extensions directly. -""" - -# Standard -import json -import logging -import re -from enum import Enum -from types import MappingProxyType -from typing import Any, Iterator, Mapping - -# First-Party -from cpex.framework.cmf.message import ( - ContentPart, - ContentType, - Message, - Resource, - Role, -) -from cpex.framework.extensions.security import ( - DataPolicy, - ObjectSecurityProfile, - SubjectExtension, -) - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Enums -# --------------------------------------------------------------------------- - - -class ViewKind(str, Enum): - """Closed-set enumeration of message view kinds. - - Maps one-to-one with ContentType, identifying the kind of - content that a view represents. - - Attributes: - TEXT: Plain text content. - THINKING: Reasoning/chain-of-thought content. - TOOL_CALL: Tool/function invocation. - TOOL_RESULT: Result from tool execution. - RESOURCE: Embedded resource with content. - RESOURCE_REF: Reference to a resource (URI only). - PROMPT_REQUEST: Prompt template request. - PROMPT_RESULT: Rendered prompt result. - IMAGE: Image content. - VIDEO: Video content. - AUDIO: Audio content. - DOCUMENT: Document content. - - Examples: - >>> ViewKind.TOOL_CALL - - >>> ViewKind.TOOL_CALL.value - 'tool_call' - """ - - TEXT = "text" - THINKING = "thinking" - TOOL_CALL = "tool_call" - TOOL_RESULT = "tool_result" - RESOURCE = "resource" - RESOURCE_REF = "resource_ref" - PROMPT_REQUEST = "prompt_request" - PROMPT_RESULT = "prompt_result" - IMAGE = "image" - VIDEO = "video" - AUDIO = "audio" - DOCUMENT = "document" - - -class ViewAction(str, Enum): - """Closed-set enumeration of semantic actions. - - Attributes: - READ: Reading/accessing data. - WRITE: Writing/modifying data. - EXECUTE: Executing a tool or command. - INVOKE: Invoking a prompt template. - SEND: Sending content outbound. - RECEIVE: Receiving content inbound. - GENERATE: Generating content (LLM output). - - Examples: - >>> ViewAction.EXECUTE - - """ - - READ = "read" - WRITE = "write" - EXECUTE = "execute" - INVOKE = "invoke" - SEND = "send" - RECEIVE = "receive" - GENERATE = "generate" - - -# --------------------------------------------------------------------------- -# ContentType -> ViewKind mapping -# --------------------------------------------------------------------------- - -_CONTENT_TYPE_TO_VIEW_KIND: dict[ContentType, ViewKind] = { - ContentType.TEXT: ViewKind.TEXT, - ContentType.THINKING: ViewKind.THINKING, - ContentType.TOOL_CALL: ViewKind.TOOL_CALL, - ContentType.TOOL_RESULT: ViewKind.TOOL_RESULT, - ContentType.RESOURCE: ViewKind.RESOURCE, - ContentType.RESOURCE_REF: ViewKind.RESOURCE_REF, - ContentType.PROMPT_REQUEST: ViewKind.PROMPT_REQUEST, - ContentType.PROMPT_RESULT: ViewKind.PROMPT_RESULT, - ContentType.IMAGE: ViewKind.IMAGE, - ContentType.VIDEO: ViewKind.VIDEO, - ContentType.AUDIO: ViewKind.AUDIO, - ContentType.DOCUMENT: ViewKind.DOCUMENT, -} - -_ACTION_MAP: dict[ViewKind, ViewAction] = { - ViewKind.TOOL_CALL: ViewAction.EXECUTE, - ViewKind.TOOL_RESULT: ViewAction.RECEIVE, - ViewKind.RESOURCE: ViewAction.READ, - ViewKind.RESOURCE_REF: ViewAction.READ, - ViewKind.PROMPT_REQUEST: ViewAction.INVOKE, - ViewKind.PROMPT_RESULT: ViewAction.RECEIVE, -} - -# Kinds whose action depends on message direction (role) -_DIRECTION_DEPENDENT_KINDS = frozenset( - { - ViewKind.TEXT, - ViewKind.THINKING, - ViewKind.IMAGE, - ViewKind.VIDEO, - ViewKind.AUDIO, - ViewKind.DOCUMENT, - } -) - -# Sensitive headers stripped during serialization -_SENSITIVE_HEADERS = frozenset({"authorization", "cookie", "x-api-key"}) - - -# --------------------------------------------------------------------------- -# MessageView -# --------------------------------------------------------------------------- - - -class MessageView: - """Read-only, zero-copy view over a single content part for policy evaluation. - - A MessageView provides a uniform interface for inspecting any content - part of a message — regardless of whether it's text, a tool call, a - resource, or media. Properties are computed on-demand from the - underlying content part and message extensions without copying data. - - For wrapped content parts (tool calls, resources, media, etc.), the - domain object is accessed via the wrapper's .content field. The _inner - property provides convenient access to the wrapped domain object. - - MessageViews are produced by Message.iter_views() or the standalone - iter_views() function. A single Message with multiple content parts - yields one view per part. - - Attributes: - kind: The type of content this view represents. - role: The role of the parent message. - raw: Direct access to the underlying content part. - - Examples: - >>> from cpex.framework.cmf.message import ( - ... Message, Role, TextContent, ToolCall, ToolCallContentPart, - ... ) - >>> msg = Message( - ... role=Role.ASSISTANT, - ... content=[ - ... TextContent(text="Let me look that up."), - ... ToolCallContentPart( - ... content=ToolCall( - ... tool_call_id="tc_001", - ... name="get_user", - ... arguments={"id": "123"}, - ... ), - ... ), - ... ], - ... ) - >>> views = list(iter_views(msg)) - >>> len(views) - 2 - >>> views[0].kind - - >>> views[1].kind - - >>> views[1].name - 'get_user' - >>> views[1].uri - 'tool://_/get_user' - >>> views[1].is_pre - True - """ - - __slots__ = ("_part", "_kind", "_message", "_extensions", "_hook") - - def __init__( - self, - part: ContentPart, - kind: ViewKind, - message: Message, - hook: str | None = None, - extensions: Any = None, - ) -> None: - """Initialize a MessageView. - - Args: - part: The underlying content part. - kind: The kind of content. - message: The parent message (for role access). - hook: The hook location where this view is being evaluated - (e.g., "llm_input", "tool_post_invoke"). None if unset. - extensions: The Extensions object, passed separately from the - message for capability-gated filtering. - """ - self._part = part - self._kind = kind - self._message = message - self._extensions = extensions - self._hook = hook - - # ========================================================================= - # Internal Helpers - # ========================================================================= - - @property - def _inner(self) -> Any: - """Get the wrapped domain object for composite content parts. - - For TextContent/ThinkingContent (which have no wrapper), returns - the part itself. For all other types, returns the .content field - which holds the domain object (ToolCall, Resource, etc.). - - Returns: - The domain object for this content part. - """ - if self._kind in (ViewKind.TEXT, ViewKind.THINKING): - return self._part - return self._part.content # type: ignore[union-attr] - - # ========================================================================= - # Core Properties - # ========================================================================= - - @property - def kind(self) -> ViewKind: - """The type of content this view represents. - - Returns: - The ViewKind for this view. - """ - return self._kind - - @property - def role(self) -> Role: - """The role of the parent message. - - Returns: - The Role (user, assistant, system, developer, tool). - """ - return self._message.role - - @property - def hook(self) -> str | None: - """The hook location where this view is being evaluated. - - Indicates where in the pipeline the evaluation is occurring - (e.g., "llm_input", "llm_output", "tool_pre_invoke", - "tool_post_invoke"). None if not set. - - Returns: - Hook location string or None. - """ - return self._hook - - @property - def raw(self) -> ContentPart: - """Direct access to the underlying content part. - - Returns: - The underlying ContentPart subclass instance. - """ - return self._part - - @property - def content(self) -> str | None: - """Scannable text content. - - For text/thinking: the text itself. For tool calls and prompt - requests: JSON-serialized arguments. For tool results: - JSON-serialized content. For resources: embedded content string. - - Returns: - Scannable text or None if no text content is available. - """ - inner = self._inner - - if self._kind in (ViewKind.TEXT, ViewKind.THINKING): - return inner.text - - if self._kind == ViewKind.RESOURCE: - return inner.content - - if self._kind == ViewKind.TOOL_CALL: - try: - return json.dumps(inner.arguments) - except (TypeError, ValueError): - return str(inner.arguments) - - if self._kind == ViewKind.TOOL_RESULT: - result_content = inner.content - if result_content is None: - return None - if isinstance(result_content, str): - return result_content - try: - return json.dumps(result_content) - except (TypeError, ValueError): - return str(result_content) - - if self._kind == ViewKind.PROMPT_REQUEST: - try: - return json.dumps(inner.arguments) - except (TypeError, ValueError): - return str(inner.arguments) - - if self._kind == ViewKind.PROMPT_RESULT: - return inner.content - - return None - - @property - def uri(self) -> str | None: - """Synthetic identity URI. - - Tools: tool://namespace/name. Tool results: tool_result://name. - Prompts: prompt://server/name. Prompt results: prompt_result://name. - Resources: the resource's own URI. - - Returns: - URI string or None if not applicable. - """ - inner = self._inner - - if self._kind in (ViewKind.RESOURCE, ViewKind.RESOURCE_REF): - return inner.uri - - if self._kind == ViewKind.TOOL_CALL: - ns = inner.namespace or "_" - return f"tool://{ns}/{inner.name}" - - if self._kind == ViewKind.TOOL_RESULT: - return f"tool_result://{inner.tool_name}" - - if self._kind == ViewKind.PROMPT_REQUEST: - server = inner.server_id or "_" - return f"prompt://{server}/{inner.name}" - - if self._kind == ViewKind.PROMPT_RESULT: - return f"prompt_result://{inner.prompt_name}" - - return None - - @property - def name(self) -> str | None: - """Human-readable name (tool name, resource name, prompt name). - - Returns: - Name string or None if not applicable. - """ - inner = self._inner - - if self._kind in (ViewKind.TOOL_CALL, ViewKind.PROMPT_REQUEST): - return inner.name - - if self._kind in (ViewKind.RESOURCE, ViewKind.RESOURCE_REF): - return inner.name - - if self._kind == ViewKind.TOOL_RESULT: - return inner.tool_name - - if self._kind == ViewKind.PROMPT_RESULT: - return inner.prompt_name - - return None - - @property - def action(self) -> ViewAction: - """The semantic action this view represents. - - For content kinds like text and media, the action depends on - the message role: SEND for user/system/developer input, - GENERATE for assistant output, RECEIVE for tool output. - - Returns: - A ViewAction value. - """ - fixed = _ACTION_MAP.get(self._kind) - if fixed is not None: - return fixed - role = self._message.role - if role == Role.ASSISTANT: - return ViewAction.GENERATE - if role == Role.TOOL: - return ViewAction.RECEIVE - return ViewAction.SEND - - @property - def args(self) -> dict[str, Any] | None: - """Arguments dict for tool calls and prompt requests. - - Returns: - Arguments dict or None for other content types. - """ - inner = self._inner - if self._kind == ViewKind.TOOL_CALL: - return inner.arguments - if self._kind == ViewKind.PROMPT_REQUEST: - return inner.arguments - return None - - @property - def mime_type(self) -> str | None: - """MIME type if applicable. - - Returns: - MIME type string or None. - """ - inner = self._inner - if self._kind == ViewKind.RESOURCE: - return inner.mime_type - if self._kind in (ViewKind.IMAGE, ViewKind.VIDEO, ViewKind.AUDIO, ViewKind.DOCUMENT): - return inner.media_type - return None - - @property - def size_bytes(self) -> int | None: - """Content size in bytes (computed from content). - - Returns: - Size in bytes or None. - """ - if self._kind == ViewKind.RESOURCE: - res: Resource = self._inner - if res.size_bytes is not None: - return res.size_bytes - if res.content: - return len(res.content.encode("utf-8")) - if res.blob: - return len(res.blob) - return None - - text = self.content - if text is not None: - return len(text.encode("utf-8")) - return None - - @property - def properties(self) -> dict[str, Any]: - """Type-specific properties as a dict. - - For single property access, prefer get_property() which - avoids allocating a dict. - - Returns: - Dict of property name to value for this view's kind. - """ - props: dict[str, Any] = {} - inner = self._inner - - if self._kind == ViewKind.RESOURCE: - props["resource_type"] = inner.resource_type.value - props["version"] = inner.version - props["annotations"] = inner.annotations - - elif self._kind == ViewKind.TOOL_CALL: - props["namespace"] = inner.namespace - props["tool_id"] = inner.tool_call_id - - elif self._kind == ViewKind.TOOL_RESULT: - props["is_error"] = inner.is_error - props["tool_name"] = inner.tool_name - - elif self._kind == ViewKind.PROMPT_REQUEST: - props["server_id"] = inner.server_id - - elif self._kind == ViewKind.PROMPT_RESULT: - props["is_error"] = inner.is_error - props["message_count"] = len(inner.messages) if inner.messages else 0 - - return props - - def get_property(self, name: str, default: Any = None) -> Any: - """Get a single type-specific property without allocating a dict. - - Args: - name: Property name to retrieve. - default: Value to return if property doesn't exist. - - Returns: - The property value or default. - """ - inner = self._inner - - if self._kind == ViewKind.RESOURCE: - if name == "resource_type": - return inner.resource_type.value - if name == "version": - return inner.version - if name == "annotations": - return inner.annotations - - elif self._kind == ViewKind.TOOL_CALL: - if name == "namespace": - return inner.namespace - if name == "tool_id": - return inner.tool_call_id - - elif self._kind == ViewKind.TOOL_RESULT: - if name == "is_error": - return inner.is_error - if name == "tool_name": - return inner.tool_name - - elif self._kind == ViewKind.PROMPT_REQUEST: - if name == "server_id": - return inner.server_id - - elif self._kind == ViewKind.PROMPT_RESULT: - if name == "is_error": - return inner.is_error - if name == "message_count": - return len(inner.messages) if inner.messages else 0 - - return default - - # ========================================================================= - # Direction - # ========================================================================= - - @property - def is_pre(self) -> bool: - """True if this represents input/request content (before processing). - - Determined by ViewKind for requests/responses, and by Role - for text, thinking, and media content. - - Returns: - True if this is pre-processing content. - """ - if self._kind in (ViewKind.TOOL_CALL, ViewKind.PROMPT_REQUEST, ViewKind.RESOURCE_REF): - return True - if self._kind in (ViewKind.TOOL_RESULT, ViewKind.PROMPT_RESULT, ViewKind.RESOURCE): - return False - return self._message.role in (Role.USER, Role.SYSTEM, Role.DEVELOPER) - - @property - def is_post(self) -> bool: - """True if this represents output/response content (after processing). - - Returns: - True if this is post-processing content. - """ - if self._kind in (ViewKind.TOOL_RESULT, ViewKind.PROMPT_RESULT, ViewKind.RESOURCE): - return True - if self._kind in (ViewKind.TOOL_CALL, ViewKind.PROMPT_REQUEST, ViewKind.RESOURCE_REF): - return False - return self._message.role in (Role.ASSISTANT, Role.TOOL) - - @property - def is_tool(self) -> bool: - """True if tool_call or tool_result. - - Returns: - True if this is a tool-related view. - """ - return self._kind in (ViewKind.TOOL_CALL, ViewKind.TOOL_RESULT) - - @property - def is_prompt(self) -> bool: - """True if prompt_request or prompt_result. - - Returns: - True if this is a prompt-related view. - """ - return self._kind in (ViewKind.PROMPT_REQUEST, ViewKind.PROMPT_RESULT) - - @property - def is_resource(self) -> bool: - """True if resource or resource_ref. - - Returns: - True if this is a resource-related view. - """ - return self._kind in (ViewKind.RESOURCE, ViewKind.RESOURCE_REF) - - @property - def is_text(self) -> bool: - """True if text or thinking. - - Returns: - True if this is text-based content. - """ - return self._kind in (ViewKind.TEXT, ViewKind.THINKING) - - @property - def is_media(self) -> bool: - """True if image, video, audio, or document. - - Returns: - True if this is media content. - """ - return self._kind in (ViewKind.IMAGE, ViewKind.VIDEO, ViewKind.AUDIO, ViewKind.DOCUMENT) - - # ========================================================================= - # Flat Accessors (capability-gated in the spec) - # ========================================================================= - - def _ext(self) -> Any: - """Get the extensions, or None.""" - return self._extensions - - # --- Base tier (no capability required) --- - - @property - def environment(self) -> str | None: - """Execution environment (production, staging, dev). - - Capability: base (no requirement). - - Returns: - Environment string or None. - """ - ext = self._ext() - if ext and ext.request: - return ext.request.environment - return None - - @property - def request_id(self) -> str | None: - """Request correlation ID. - - Capability: base (no requirement). - - Returns: - Request ID string or None. - """ - ext = self._ext() - if ext and ext.request: - return ext.request.request_id - return None - - # --- read_subject --- - - @property - def subject(self) -> SubjectExtension | None: - """The authenticated entity making the request. - - Capability: read_subject. - - Returns: - SubjectExtension or None. - """ - ext = self._ext() - if ext and ext.security: - return ext.security.subject - return None - - # --- read_roles --- - - @property - def roles(self) -> frozenset[str]: - """Subject's assigned roles. - - Capability: read_roles. - - Returns: - Frozenset of role strings. - """ - s = self.subject - return s.roles if s else frozenset() - - # --- read_permissions --- - - @property - def permissions(self) -> frozenset[str]: - """Subject's granted permissions. - - Capability: read_permissions. - - Returns: - Frozenset of permission strings. - """ - s = self.subject - return s.permissions if s else frozenset() - - # --- read_teams --- - - @property - def teams(self) -> frozenset[str]: - """Subject's team memberships. - - Capability: read_teams. - - Returns: - Frozenset of team strings. - """ - s = self.subject - return s.teams if s else frozenset() - - # --- read_headers --- - - @property - def headers(self) -> Mapping[str, str]: - """HTTP headers associated with the request. - - Capability: read_headers. - - .. note:: - This returns raw headers including sensitive values - (Authorization, Cookie, X-API-Key). This is by design — - plugins with ``read_headers`` capability are trusted to - see them. Sensitive headers are only stripped in - ``to_dict()`` serialization, not in direct property access. - - Returns: - Read-only mapping of header name to value. - """ - ext = self._ext() - if ext and ext.http: - return MappingProxyType(ext.http.headers) - return MappingProxyType({}) - - # --- read_labels --- - - @property - def labels(self) -> frozenset[str]: - """Security/data labels on this message. - - Capability: read_labels. - - Returns: - Frozenset of label strings. - """ - ext = self._ext() - if ext and ext.security: - return ext.security.labels - return frozenset() - - # --- read_agent --- - - @property - def agent_input(self) -> str | None: - """Original user intent that triggered this action. - - Capability: read_agent. - - Returns: - Input string or None. - """ - ext = self._ext() - if ext and ext.agent: - return ext.agent.input - return None - - @property - def session_id(self) -> str | None: - """Broad session identifier. - - Capability: read_agent. - - Returns: - Session ID string or None. - """ - ext = self._ext() - if ext and ext.agent: - return ext.agent.session_id - return None - - @property - def conversation_id(self) -> str | None: - """Specific dialogue/task within a session. - - Capability: read_agent. - - Returns: - Conversation ID string or None. - """ - ext = self._ext() - if ext and ext.agent: - return ext.agent.conversation_id - return None - - @property - def turn(self) -> int | None: - """Position in conversation (0-indexed). - - Capability: read_agent. - - Returns: - Turn number or None. - """ - ext = self._ext() - if ext and ext.agent: - return ext.agent.turn - return None - - @property - def agent_id(self) -> str | None: - """Identifier of the producing agent. - - Capability: read_agent. - - Returns: - Agent ID string or None. - """ - ext = self._ext() - if ext and ext.agent: - return ext.agent.agent_id - return None - - @property - def parent_agent_id(self) -> str | None: - """Spawning agent's ID (multi-agent lineage). - - Capability: read_agent. - - Returns: - Parent agent ID string or None. - """ - ext = self._ext() - if ext and ext.agent: - return ext.agent.parent_agent_id - return None - - # --- read_objects --- - - @property - def object(self) -> ObjectSecurityProfile | None: - """Access control profile for this view's entity. - - Resolved by view.name from extensions.security.objects. - - Capability: read_objects. - - Returns: - ObjectSecurityProfile or None. - """ - ext = self._ext() - view_name = self.name - if ext and ext.security and view_name: - return ext.security.objects.get(view_name) - return None - - # --- read_data --- - - @property - def data_policy(self) -> DataPolicy | None: - """Data governance policy for this view's entity. - - Resolved by view.name from extensions.security.data. - - Capability: read_data. - - Returns: - DataPolicy or None. - """ - ext = self._ext() - view_name = self.name - if ext and ext.security and view_name: - return ext.security.data.get(view_name) - return None - - # ========================================================================= - # Helper Methods - # ========================================================================= - - def has_role(self, role: str) -> bool: - """Check if subject has a specific role. - - Args: - role: The role to check for. - - Returns: - True if the subject has the role. - """ - return role in self.roles - - def has_permission(self, perm: str) -> bool: - """Check if subject has a specific permission. - - Args: - perm: The permission to check for. - - Returns: - True if the subject has the permission. - """ - return perm in self.permissions - - def has_label(self, label: str) -> bool: - """Check if a security label is present. - - Args: - label: Label to check for (e.g., "PII", "SECRET"). - - Returns: - True if the label is present. - """ - return label in self.labels - - def has_header(self, name: str) -> bool: - """Check if an HTTP header exists (case-insensitive). - - Args: - name: Header name to check. - - Returns: - True if header exists. - """ - return self.get_header(name) is not None - - def get_header(self, name: str, default: str | None = None) -> str | None: - """Get an HTTP header value (case-insensitive). - - Args: - name: Header name. - default: Default value if header not found. - - Returns: - Header value or default. - """ - lower_name = name.lower() - for key, value in self.headers.items(): - if key.lower() == lower_name: - return value - return default - - def get_arg(self, name: str, default: Any = None) -> Any: - """Get a single argument value. - - Args: - name: Argument name. - default: Value if argument doesn't exist. - - Returns: - Argument value or default. - """ - args = self.args - if args is None: - return default - return args.get(name, default) - - def has_arg(self, name: str) -> bool: - """Check if an argument exists. - - Args: - name: Argument name to check. - - Returns: - True if argument exists. - """ - args = self.args - return args is not None and name in args - - def matches_uri_pattern(self, pattern: str) -> bool: - """Check if URI matches a glob-style pattern. - - Supports * (single segment) and ** (any number of segments) - wildcards. - - Args: - pattern: Glob pattern to match against. - - Returns: - True if URI matches the pattern. - """ - view_uri = self.uri - if not view_uri: - return False - # Split on ** first, then * within each segment, escaping literals - parts = pattern.split("**") - regex_parts = [] - for part in parts: - sub_parts = part.split("*") - regex_parts.append("[^/]*".join(re.escape(s) for s in sub_parts)) - regex = f"^{'.*'.join(regex_parts)}$" - return bool(re.match(regex, view_uri)) - - def has_content(self) -> bool: - """True if scannable text content is available. - - Returns: - True if content is not None. - """ - return self.content is not None - - # ========================================================================= - # Serialization - # ========================================================================= - - def to_dict(self, include_content: bool = True, include_context: bool = True) -> dict[str, Any]: - """Serialize the view to a JSON-compatible dictionary. - - Sensitive headers (Authorization, Cookie, X-API-Key) are - automatically stripped from the serialized output. - - Args: - include_content: Include text content (may be large). - include_context: Include extensions context. - - Returns: - JSON-serializable dictionary with view properties. - """ - result: dict[str, Any] = { - "kind": self._kind.value, - "role": self._message.role.value, - "is_pre": self.is_pre, - "is_post": self.is_post, - "action": self.action.value, - } - - if self._hook is not None: - result["hook"] = self._hook - - if self.uri: - result["uri"] = self.uri - if self.name: - result["name"] = self.name - - if include_content: - text = self.content - if text is not None: - result["content"] = text - result["size_bytes"] = len(text.encode("utf-8")) - else: - size = self.size_bytes - if size is not None: - result["size_bytes"] = size - - if self.mime_type: - result["mime_type"] = self.mime_type - - args = self.args - if args is not None: - result["arguments"] = args - - props = self.properties - if props: - result["properties"] = props - - if include_context: - extensions: dict[str, Any] = {} - - # Subject - s = self.subject - if s: - extensions["subject"] = { - "id": s.id, - "type": s.type.value, - "roles": sorted(s.roles), - "permissions": sorted(s.permissions), - "teams": sorted(s.teams), - } - - # Environment - env = self.environment - if env: - extensions["environment"] = env - - # Labels - lbls = self.labels - if lbls: - extensions["labels"] = sorted(lbls) - - # Headers (strip sensitive) - hdrs = self.headers - if hdrs: - safe = {k: v for k, v in hdrs.items() if k.lower() not in _SENSITIVE_HEADERS} - if safe: - extensions["headers"] = safe - - # Object profile (for pre views) - obj = self.object - if obj: - extensions["object"] = { - "managed_by": obj.managed_by, - "permissions": obj.permissions, - "trust_domain": obj.trust_domain, - "data_scope": obj.data_scope, - } - - # Data policy (for post views) - dp = self.data_policy - if dp: - dp_dict: dict[str, Any] = { - "apply_labels": dp.apply_labels, - "denied_actions": dp.denied_actions, - } - if dp.allowed_actions is not None: - dp_dict["allowed_actions"] = dp.allowed_actions - if dp.retention: - dp_dict["retention"] = { - "max_age_seconds": dp.retention.max_age_seconds, - "policy": dp.retention.policy, - "delete_after": dp.retention.delete_after, - } - extensions["data"] = dp_dict - - # Agent context - ext = self._ext() - if ext and ext.agent: - agent_dict: dict[str, Any] = {} - if ext.agent.input: - agent_dict["input"] = ext.agent.input - if ext.agent.session_id: - agent_dict["session_id"] = ext.agent.session_id - if ext.agent.conversation_id: - agent_dict["conversation_id"] = ext.agent.conversation_id - if ext.agent.turn is not None: - agent_dict["turn"] = ext.agent.turn - if ext.agent.agent_id: - agent_dict["agent_id"] = ext.agent.agent_id - if ext.agent.parent_agent_id: - agent_dict["parent_agent_id"] = ext.agent.parent_agent_id - if agent_dict: - extensions["agent"] = agent_dict - - if extensions: - result["extensions"] = extensions - - return result - - def to_opa_input(self, include_content: bool = True) -> dict[str, Any]: - """Serialize to OPA-compatible input format. - - Wraps the view in the standard OPA input envelope: - {"input": {...view data...}}. - - Args: - include_content: Include text content in the input. - - Returns: - Dict in OPA input format. - """ - return {"input": self.to_dict(include_content=include_content)} - - def __repr__(self) -> str: - """String representation of the view. - - Returns: - Human-readable representation. - """ - role_part = f", role={self._message.role.value}" - uri_part = f", uri={self.uri}" if self.uri else "" - hook_part = f", hook={self._hook}" if self._hook else "" - direction = "pre" if self.is_pre else "post" if self.is_post else "?" - return f"MessageView(kind={self._kind.value}{role_part}, {direction}{uri_part}{hook_part})" - - -# --------------------------------------------------------------------------- -# View Iterator (standalone) -# --------------------------------------------------------------------------- - - -def iter_views(message: Message, hook: str | None = None, extensions: Any = None) -> Iterator[MessageView]: - """Iterate over a message yielding one MessageView per content part. - - Memory-efficient: views are yielded one at a time and hold only - references to the underlying message and content part. - - This is the standalone version. Message.iter_views() delegates - to this function. - - Args: - message: The message to decompose into views. - hook: Optional hook location string (e.g., "llm_input", - "tool_post_invoke") to attach to each view. - - Yields: - A MessageView for each content part in the message. - - Examples: - >>> from cpex.framework.cmf.message import ( - ... Message, Role, TextContent, ToolCall, ToolCallContentPart, - ... ThinkingContent, - ... ) - >>> msg = Message( - ... role=Role.ASSISTANT, - ... content=[ - ... ThinkingContent(text="User wants admin users."), - ... TextContent(text="Let me look that up."), - ... ToolCallContentPart( - ... content=ToolCall( - ... tool_call_id="tc_001", - ... name="execute_sql", - ... arguments={"query": "SELECT * FROM users"}, - ... ), - ... ), - ... ], - ... ) - >>> views = list(iter_views(msg)) - >>> len(views) - 3 - >>> [(v.kind.value, v.is_pre) for v in views] - [('thinking', False), ('text', False), ('tool_call', True)] - """ - for part in message.content: - kind = _CONTENT_TYPE_TO_VIEW_KIND.get(part.content_type) - if kind is None: - logger.warning("Unknown content type %r in iter_views", part.content_type) - raise ValueError(f"Unknown content type: {part.content_type!r}") - yield MessageView(part, kind, message, hook=hook, extensions=extensions) diff --git a/cpex/framework/constants.py b/cpex/framework/constants.py deleted file mode 100644 index 64d8e090..00000000 --- a/cpex/framework/constants.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/constants.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Plugins constants file. -This module stores a collection of plugin constants used throughout the framework. -""" - -# Standard - -# Model constants. -# Specialized plugin types. -EXTERNAL_PLUGIN_TYPE = "external" -ISOLATED_VENV_PLUGIN_TYPE = "isolated_venv" - - -# MCP related constants. -PYTHON_SUFFIX = ".py" -URL = "url" -SCRIPT = "script" -CMD = "cmd" -ENV = "env" -CWD = "cwd" -UDS = "uds" - -NAME = "name" -PLUGIN_NAME = "plugin_name" -PAYLOAD = "payload" -CONTEXT = "context" -RESULT = "result" -ERROR = "error" -IGNORE_CONFIG_EXTERNAL = "ignore_config_external" - -# Global Context Metadata fields - -TOOL_METADATA = "tool" -GATEWAY_METADATA = "gateway" - -# MCP Plugin Server Runtime constants -MCP_SERVER_NAME = "MCP Plugin Server" -MCP_SERVER_INSTRUCTIONS = "External plugin server for ContextForge" -GET_PLUGIN_CONFIGS = "get_plugin_configs" -GET_PLUGIN_CONFIG = "get_plugin_config" -HOOK_TYPE = "hook_type" -INVOKE_HOOK = "invoke_hook" diff --git a/cpex/framework/decorator.py b/cpex/framework/decorator.py deleted file mode 100644 index a73c9f7a..00000000 --- a/cpex/framework/decorator.py +++ /dev/null @@ -1,198 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/decorator.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Hook decorator for dynamically registering plugin hooks. - -This module provides decorators for marking plugin methods as hook handlers. -Plugins can use these decorators to: -1. Override the default hook naming convention -2. Register custom hooks not in the standard framework - -Examples: - Override hook method name:: - - class MyPlugin(Plugin): - @hook(ToolHookType.TOOL_PRE_INVOKE) - def custom_name_for_tool_hook(self, payload, context): - # This gets called for tool_pre_invoke even though - # the method name doesn't match - return ToolPreInvokeResult(continue_processing=True) - - Register a completely new hook type:: - - class MyPlugin(Plugin): - @hook("custom_pre_process", CustomPayload, CustomResult) - def my_custom_hook(self, payload, context): - # This registers a new hook type dynamically - return CustomResult(continue_processing=True) - - Use default convention (no decorator needed):: - - class MyPlugin(Plugin): - def tool_pre_invoke(self, payload, context): - # Automatically recognized by naming convention - return ToolPreInvokeResult(continue_processing=True) -""" - -# Standard -from typing import Callable, Optional, Sequence, Type, TypeVar, Union - -# Third-Party -from pydantic import BaseModel - -# First-Party -from cpex.framework.models import PluginPayload, PluginResult - -# Attribute name for storing hook metadata on functions -_HOOK_METADATA_ATTR = "_plugin_hook_metadata" - -# Type vars for type hints -P = TypeVar("P", bound=PluginPayload) # Payload type -R = TypeVar("R", bound=PluginResult) # Result type - - -class HookMetadata: - """Metadata stored on decorated hook methods. - - Attributes: - hook_types: The hook type identifiers this method handles. - payload_type: Optional payload class for hook registration. - result_type: Optional result class for hook registration. - """ - - def __init__( - self, - hook_types: list[str], - payload_type: Optional[Type[BaseModel]] = None, - result_type: Optional[Type[BaseModel]] = None, - ): - """Initialize hook metadata. - - Args: - hook_types: List of hook type identifiers this method handles. - payload_type: Optional payload class for registering new hooks. - result_type: Optional result class for registering new hooks. - """ - self.hook_types = hook_types - self.payload_type = payload_type - self.result_type = result_type - - @property - def hook_type(self) -> str: - """Primary hook type (first in list). For backward compatibility.""" - return self.hook_types[0] if self.hook_types else "" - - def matches(self, hook_type: str) -> bool: - """Check if this metadata handles the given hook type.""" - return hook_type in self.hook_types - - -def hook( - hook_type: Union[str, Sequence[str]], - payload_type: Optional[Type[P]] = None, - result_type: Optional[Type[R]] = None, -) -> Callable[[Callable], Callable]: - """Decorator to mark a method as a plugin hook handler. - - This decorator attaches metadata to a method so the Plugin class can - discover it during initialization and register it with the appropriate - hook type(s). - - Args: - hook_type: One or more hook type identifiers. Pass a string for - a single hook, or a list/tuple to register the same method - for multiple hook points. - payload_type: Optional payload class for registering new hook types. - result_type: Optional result class for registering new hook types. - - Returns: - Decorator function that marks the method with hook metadata. - - Examples: - Single hook:: - - @hook(ToolHookType.TOOL_PRE_INVOKE) - def my_custom_method_name(self, payload, context): - return ToolPreInvokeResult(continue_processing=True) - - Multiple hooks (same method handles both):: - - @hook([CmfHookType.TOOL_PRE_INVOKE, CmfHookType.TOOL_POST_INVOKE]) - def evaluate(self, payload, context): - return MessageResult() - - Register new hook type:: - - @hook("email_pre_send", EmailPayload, EmailResult) - def handle_email(self, payload, context): - return EmailResult(continue_processing=True) - """ - - def decorator(func: Callable) -> Callable: - """Inner decorator that attaches metadata to the function. - - Args: - func: The function to decorate - - Returns: - The same function with metadata attached - """ - # Normalize to list - if isinstance(hook_type, str): - types = [hook_type] - else: - types = list(hook_type) - - metadata = HookMetadata(types, payload_type, result_type) - setattr(func, _HOOK_METADATA_ATTR, metadata) - return func - - return decorator - - -def get_hook_metadata(func: Callable) -> Optional[HookMetadata]: - """Get hook metadata from a decorated function. - - Args: - func: The function to check - - Returns: - HookMetadata if the function is decorated, None otherwise - - Examples: - >>> @hook("test_hook") - ... def test_func(): - ... pass - >>> metadata = get_hook_metadata(test_func) - >>> metadata.hook_type - 'test_hook' - >>> metadata.matches("test_hook") - True - >>> get_hook_metadata(lambda: None) is None - True - """ - return getattr(func, _HOOK_METADATA_ATTR, None) - - -def has_hook_metadata(func: Callable) -> bool: - """Check if a function has hook metadata. - - Args: - func: The function to check - - Returns: - True if the function is decorated with @hook, False otherwise - - Examples: - >>> @hook("test_hook") - ... def decorated(): - ... pass - >>> has_hook_metadata(decorated) - True - >>> has_hook_metadata(lambda: None) - False - """ - return hasattr(func, _HOOK_METADATA_ATTR) diff --git a/cpex/framework/errors.py b/cpex/framework/errors.py deleted file mode 100644 index d1e41874..00000000 --- a/cpex/framework/errors.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/errors.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Pydantic models for plugins. -This module implements the pydantic models associated with -the base plugin layer including configurations, and contexts. -""" - -# First-Party -from cpex.framework.models import PluginErrorModel, PluginViolation - - -class PluginViolationError(Exception): - """A plugin violation error. - - Attributes: - violation (PluginViolation): the plugin violation. - message (str): the plugin violation reason. - """ - - def __init__(self, message: str, violation: PluginViolation | None = None): - """Initialize a plugin violation error. - - Args: - message: the reason for the violation error. - violation: the plugin violation object details. - - Examples: - >>> from cpex.framework.errors import PluginViolationError - >>> from cpex.framework.models import PluginViolation - >>> v = PluginViolation(reason="r", description="d", code="c") - >>> err = PluginViolationError("blocked", violation=v) - >>> (str(err), err.violation.code) - ('blocked', 'c') - """ - self.message = message - self.violation = violation - super().__init__(self.message) - - -class PluginError(Exception): - """A plugin error object for errors internal to the plugin. - - Attributes: - error (PluginErrorModel): the plugin error object. - """ - - def __init__(self, error: PluginErrorModel): - """Initialize a plugin violation error. - - Args: - error: the plugin error details. - - Examples: - >>> from cpex.framework.errors import PluginError - >>> from cpex.framework.models import PluginErrorModel - >>> pe = PluginError(PluginErrorModel(message="boom", plugin_name="p1")) - >>> (str(pe), pe.error.plugin_name) - ('boom', 'p1') - """ - self.error = error - super().__init__(self.error.message) - - -def convert_exception_to_error(exception: Exception, plugin_name: str) -> PluginErrorModel: - """Converts an exception object into a PluginErrorModel. Primarily used for external plugin error handling. - - Args: - exception: The exception to be converted. - plugin_name: The name of the plugin on which the exception occurred. - - Returns: - A plugin error pydantic object that can be sent over HTTP. - - Examples: - >>> from cpex.framework.errors import convert_exception_to_error - >>> err = convert_exception_to_error(ValueError("nope"), plugin_name="p1") - >>> (err.plugin_name, "ValueError('nope')" in err.message) - ('p1', True) - """ - return PluginErrorModel(message=repr(exception), plugin_name=plugin_name) diff --git a/cpex/framework/extensions/__init__.py b/cpex/framework/extensions/__init__.py deleted file mode 100644 index 07935992..00000000 --- a/cpex/framework/extensions/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Extensions Package. -Provides structured, typed extension models for identity, security, -governance, and execution context metadata. Extensions are designed -to be reusable across different payload types. -""" - -# First-Party -from cpex.framework.extensions.agent import AgentExtension, ConversationContext -from cpex.framework.extensions.completion import CompletionExtension, StopReason, TokenUsage -from cpex.framework.extensions.constants import SlotName -from cpex.framework.extensions.delegation import DelegationExtension, DelegationHop -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.framework import FrameworkExtension -from cpex.framework.extensions.http import HttpExtension -from cpex.framework.extensions.llm import LLMExtension -from cpex.framework.extensions.mcp import ( - MCPExtension, - PromptMetadata, - ResourceMetadata, - ToolMetadata, -) -from cpex.framework.extensions.meta import MetaExtension -from cpex.framework.extensions.provenance import ProvenanceExtension -from cpex.framework.extensions.request import RequestExtension -from cpex.framework.extensions.security import ( - DataPolicy, - ObjectSecurityProfile, - RetentionPolicy, - SecurityExtension, - SubjectExtension, - SubjectType, -) -from cpex.framework.extensions.tiers import ( - AccessPolicy, - Capability, - MutabilityTier, - TierViolationError, -) - -__all__ = [ - "AccessPolicy", - "SlotName", - "AgentExtension", - "Capability", - "CompletionExtension", - "ConversationContext", - "DataPolicy", - "DelegationExtension", - "DelegationHop", - "Extensions", - "FrameworkExtension", - "HttpExtension", - "LLMExtension", - "MCPExtension", - "MetaExtension", - "MutabilityTier", - "ObjectSecurityProfile", - "PromptMetadata", - "ProvenanceExtension", - "RequestExtension", - "ResourceMetadata", - "RetentionPolicy", - "SecurityExtension", - "StopReason", - "SubjectExtension", - "SubjectType", - "TierViolationError", - "TokenUsage", - "ToolMetadata", -] diff --git a/cpex/framework/extensions/agent.py b/cpex/framework/extensions/agent.py deleted file mode 100644 index 87450984..00000000 --- a/cpex/framework/extensions/agent.py +++ /dev/null @@ -1,99 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/agent.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Agent extension models. -Carries agent execution context — session tracking, multi-agent lineage, -original user intent, and optional windowed conversation history. -Immutable tier — the user's intent and session identity must not be -modifiable by processing components. -""" - -# Standard - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class ConversationContext(BaseModel): - """Windowed conversation context for agent-aware processing. - - Provides a lightweight view of prior conversation history without - requiring access to the full message store. - - Attributes: - history: Windowed message history (recent turns). - summary: Summarized prior context. - topics: Extracted topics or intents. - - Examples: - >>> ctx = ConversationContext( - ... summary="User asked about quarterly revenue.", - ... topics=["revenue", "Q4"], - ... ) - >>> ctx.summary - 'User asked about quarterly revenue.' - >>> ctx.topics - ['revenue', 'Q4'] - """ - - model_config = ConfigDict(frozen=True) - - history: list[BaseModel] = Field( - default_factory=list, - description="Windowed message history (recent turns). Each entry is a typed model (e.g., CMF Message).", - max_length=100, - ) - summary: str | None = Field(default=None, description="Summarized prior context.") - topics: list[str] = Field(default_factory=list, description="Extracted topics or intents.") - - -class AgentExtension(BaseModel): - """Agent execution context. - - Tracks session identity, multi-agent lineage, and the original - user intent that triggered the current action. Immutable — the - processing pipeline rejects any modifications. - - Attributes: - input: Original user intent that triggered this action. - session_id: Broad session identifier. - conversation_id: Specific dialogue/task within a session. - turn: Position in conversation (0-indexed). - agent_id: Identifier of the producing agent. - parent_agent_id: Spawning agent's ID (multi-agent lineage). - conversation: Windowed conversation context. - - Examples: - >>> ext = AgentExtension( - ... input="What is the weather in London?", - ... session_id="sess-001", - ... conversation_id="conv-042", - ... turn=3, - ... agent_id="weather-agent", - ... ) - >>> ext.input - 'What is the weather in London?' - >>> ext.turn - 3 - - >>> # Multi-agent lineage - >>> child = AgentExtension( - ... agent_id="sub-agent-01", - ... parent_agent_id="weather-agent", - ... ) - >>> child.parent_agent_id - 'weather-agent' - """ - - model_config = ConfigDict(frozen=True) - - input: str | None = Field(default=None, description="Original user intent that triggered this action.") - session_id: str | None = Field(default=None, description="Broad session identifier.") - conversation_id: str | None = Field(default=None, description="Specific dialogue/task within a session.") - turn: int | None = Field(default=None, description="Position in conversation (0-indexed).") - agent_id: str | None = Field(default=None, description="Identifier of the producing agent.") - parent_agent_id: str | None = Field(default=None, description="Spawning agent's ID (multi-agent lineage).") - conversation: ConversationContext | None = Field(default=None, description="Windowed conversation context.") diff --git a/cpex/framework/extensions/completion.py b/cpex/framework/extensions/completion.py deleted file mode 100644 index e4be1520..00000000 --- a/cpex/framework/extensions/completion.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/completion.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Completion extension models. -Carries LLM completion information including stop reason, token usage, -model identifier, wire format, and latency. -Immutable tier — shared reference, no modifications allowed. -""" - -# Standard -from enum import Enum - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class StopReason(str, Enum): - """Closed-set enumeration of completion stop reasons. - - Attributes: - END: Natural end of generation. - RETURN: Model returned a structured result. - CALL: Model made a tool call. - MAX_TOKENS: Generation stopped due to token limit. - STOP_SEQUENCE: Generation stopped at a stop sequence. - - Examples: - >>> StopReason.END - - >>> StopReason("max_tokens") - - """ - - END = "end" - RETURN = "return" - CALL = "call" - MAX_TOKENS = "max_tokens" - STOP_SEQUENCE = "stop_sequence" - - -class TokenUsage(BaseModel): - """Token consumption metrics for a completion. - - Attributes: - input_tokens: Tokens consumed by the input. - output_tokens: Tokens generated in the output. - total_tokens: Total tokens (input + output). - - Examples: - >>> usage = TokenUsage(input_tokens=150, output_tokens=50, total_tokens=200) - >>> usage.total_tokens - 200 - """ - - model_config = ConfigDict(frozen=True) - - input_tokens: int = Field(description="Tokens consumed by the input.") - output_tokens: int = Field(description="Tokens generated in the output.") - total_tokens: int = Field(description="Total tokens (input + output).") - - -class CompletionExtension(BaseModel): - """LLM completion information. - - Fields like model and stop_reason can drive policy decisions - (e.g., "only allow gpt-4 for financial queries", "flag max_tokens - responses for review"). Immutable — the processing pipeline rejects - any modifications. - - Attributes: - stop_reason: Why the model stopped. - tokens: Token counts. - model: Model identifier that generated this response. - raw_format: Original wire format (chatml, harmony, gemini, anthropic). - created_at: ISO timestamp when the message was created. - latency_ms: Response generation time in milliseconds. - - Examples: - >>> ext = CompletionExtension( - ... stop_reason=StopReason.END, - ... tokens=TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150), - ... model="gpt-4o", - ... latency_ms=1200, - ... ) - >>> ext.stop_reason - - >>> ext.tokens.total_tokens - 150 - >>> ext.latency_ms - 1200 - """ - - model_config = ConfigDict(frozen=True) - - stop_reason: StopReason | None = Field(default=None, description="Why the model stopped.") - tokens: TokenUsage | None = Field(default=None, description="Token counts.") - model: str | None = Field(default=None, description="Model identifier that generated this response.") - raw_format: str | None = Field( - default=None, description="Original wire format (chatml, harmony, gemini, anthropic)." - ) - created_at: str | None = Field(default=None, description="ISO timestamp when the message was created.") - latency_ms: int | None = Field(default=None, description="Response generation time in milliseconds.") diff --git a/cpex/framework/extensions/constants.py b/cpex/framework/extensions/constants.py deleted file mode 100644 index 65afa4f6..00000000 --- a/cpex/framework/extensions/constants.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/constants.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Extension constants. - -Single source of truth for slot names and field names used in the -slot registry, filter_extensions(), merge_extensions(), and -validate_tier_constraints(). Using these constants instead of bare -strings prevents typo-induced duplicates. -""" - -# Standard -from __future__ import annotations - -from enum import Enum - -# --------------------------------------------------------------------------- -# Slot Registry Names (dot-notation paths for nested sub-fields) -# --------------------------------------------------------------------------- - - -class SlotName(str, Enum): - """Canonical slot names for extension fields and sub-fields. - - Top-level names correspond to Extensions model attributes. - Dotted names represent nested sub-fields (e.g., security.subject.roles). - """ - - def __str__(self) -> str: - """Return the enum value as a plain string. - - Overrides the default ``StrEnum.__str__`` which renders as - ``ClassName.MEMBER`` in Python 3.11+. - - Returns: - The raw string value of the enum member. - """ - return self.value - - REQUEST = "request" - PROVENANCE = "provenance" - COMPLETION = "completion" - LLM = "llm" - FRAMEWORK = "framework" - MCP = "mcp" - AGENT = "agent" - HTTP = "http" - META = "meta" - DELEGATION = "delegation" - CUSTOM = "custom" - - # Security sub-fields - SECURITY_SUBJECT = "security.subject" - SECURITY_SUBJECT_ROLES = "security.subject.roles" - SECURITY_SUBJECT_TEAMS = "security.subject.teams" - SECURITY_SUBJECT_CLAIMS = "security.subject.claims" - SECURITY_SUBJECT_PERMISSIONS = "security.subject.permissions" - SECURITY_OBJECTS = "security.objects" - SECURITY_DATA = "security.data" - SECURITY_LABELS = "security.labels" - - -# --------------------------------------------------------------------------- -# Pydantic Field Name Constants -# --------------------------------------------------------------------------- -# Used as keys in model_copy(update={...}) dicts and Extensions(**fields) -# construction. These match the Pydantic model attribute names exactly. - -# Extensions model fields -FIELD_REQUEST: str = "request" -FIELD_PROVENANCE: str = "provenance" -FIELD_COMPLETION: str = "completion" -FIELD_LLM: str = "llm" -FIELD_FRAMEWORK: str = "framework" -FIELD_MCP: str = "mcp" -FIELD_AGENT: str = "agent" -FIELD_HTTP: str = "http" -FIELD_META: str = "meta" -FIELD_DELEGATION: str = "delegation" -FIELD_CUSTOM: str = "custom" -FIELD_SECURITY: str = "security" - -# SecurityExtension model fields -FIELD_LABELS: str = "labels" -FIELD_CLASSIFICATION: str = "classification" -FIELD_SUBJECT: str = "subject" -FIELD_OBJECTS: str = "objects" -FIELD_DATA: str = "data" - -# SubjectExtension model fields -FIELD_ROLES: str = "roles" -FIELD_TEAMS: str = "teams" -FIELD_CLAIMS: str = "claims" -FIELD_PERMISSIONS: str = "permissions" diff --git a/cpex/framework/extensions/delegation.py b/cpex/framework/extensions/delegation.py deleted file mode 100644 index 43a2a8f9..00000000 --- a/cpex/framework/extensions/delegation.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/delegation.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Delegation extension models. -Carries the delegation chain state through the CMF message for policy -evaluation. The chain grows monotonically — each hop appends, never -removes. Scope narrowing is enforced at the framework level. - -See: docs/delegation-hooks-design.md -""" - -# Standard -from datetime import UTC, datetime - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class DelegationHop(BaseModel): - """One hop in the delegation chain. - - Each hop represents one step: "entity X delegated to entity Y - for audience Z with these scopes." Immutable once created. - - Attributes: - subject_id: Who is acting at this hop. - subject_type: Entity kind (user, agent, service). - audience: Target audience for this hop's token. - scopes_granted: What this hop's token can do. - timestamp: When this hop was created. - ttl_seconds: Token lifetime for this hop. - strategy: How the token was obtained (token_exchange, ucan, etc.). - from_cache: Whether the token came from cache. - - Examples: - >>> hop = DelegationHop( - ... subject_id="alice@corp.com", - ... subject_type="user", - ... scopes_granted=("read:compensation",), - ... timestamp=datetime(2025, 1, 1), - ... strategy="token_exchange", - ... ) - >>> hop.subject_id - 'alice@corp.com' - """ - - model_config = ConfigDict(frozen=True) - - subject_id: str = Field(description="Who is acting at this hop.") - subject_type: str = Field(description="Entity kind: user, agent, service, system.") - audience: str | None = Field(default=None, description="Target audience for this hop's token.") - scopes_granted: tuple[str, ...] = Field(default=(), description="Scopes this hop's token grants.") - timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC), description="When this hop was created.") - ttl_seconds: int | None = Field(default=None, description="Token lifetime in seconds.") - strategy: str | None = Field(default=None, description="Token strategy: token_exchange, ucan, passthrough, etc.") - from_cache: bool = Field(default=False, description="Whether the token came from cache.") - - -class DelegationExtension(BaseModel): - """Delegation chain state carried in the CMF message. - - Mutability tiers: - - chain: monotonic (grows with each hop, never shrinks) - - origin_subject_id, delegated: immutable (set at first delegation) - - actor_subject_id: updates per hop (current actor changes) - - The chain is available to the DSL via the delegation.* namespace: - delegation.origin, delegation.actor, delegation.depth, - delegation.age, delegated - - Attributes: - chain: Ordered list of delegation hops (monotonic growth). - depth: Number of hops in the chain. - origin_subject_id: Original caller (immutable once set). - actor_subject_id: Current actor (latest hop's subject). - delegated: Whether this request is delegated. - age_seconds: Seconds since the original delegation. - - Examples: - >>> ext = DelegationExtension() - >>> ext.delegated - False - >>> ext.depth - 0 - """ - - model_config = ConfigDict(frozen=True) - - chain: tuple[DelegationHop, ...] = Field(default=(), description="Ordered delegation hops.") - depth: int = Field(default=0, description="Number of hops.") - origin_subject_id: str | None = Field(default=None, description="Original caller.") - actor_subject_id: str | None = Field(default=None, description="Current actor.") - delegated: bool = Field(default=False, description="Whether this is a delegated request.") - age_seconds: float = Field(default=0.0, description="Seconds since original delegation.") - - def with_new_hop(self, hop: DelegationHop) -> "DelegationExtension": - """Create a new DelegationExtension with an appended hop. - - Returns a new instance — the original is unchanged (immutable). - The framework enforces scope narrowing before calling this. - - Args: - hop: The new delegation hop to append. - - Returns: - New DelegationExtension with the hop appended. - """ - new_chain = self.chain + (hop,) - origin = self.origin_subject_id or hop.subject_id - age = (datetime.now(UTC) - self.chain[0].timestamp).total_seconds() if self.chain else 0.0 - return DelegationExtension( - chain=new_chain, - depth=len(new_chain), - origin_subject_id=origin, - actor_subject_id=hop.subject_id, - delegated=True, - age_seconds=age, - ) diff --git a/cpex/framework/extensions/extensions.py b/cpex/framework/extensions/extensions.py deleted file mode 100644 index fdced400..00000000 --- a/cpex/framework/extensions/extensions.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/extensions.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Extensions container model. -Aggregates all typed extension models into a single container that -attaches to a Message. Each extension slot corresponds to a specific -mutability tier enforced by the processing pipeline. -""" - -# Standard -from typing import Any - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - -# First-Party -from cpex.framework.extensions.agent import AgentExtension -from cpex.framework.extensions.completion import CompletionExtension -from cpex.framework.extensions.delegation import DelegationExtension -from cpex.framework.extensions.framework import FrameworkExtension -from cpex.framework.extensions.http import HttpExtension -from cpex.framework.extensions.llm import LLMExtension -from cpex.framework.extensions.mcp import MCPExtension -from cpex.framework.extensions.meta import MetaExtension -from cpex.framework.extensions.provenance import ProvenanceExtension -from cpex.framework.extensions.request import RequestExtension -from cpex.framework.extensions.security import SecurityExtension - - -class Extensions(BaseModel): - """Container for all typed message extensions. - - Each extension slot carries contextual metadata with an explicit - mutability tier enforced by the processing pipeline during - copy-on-write operations. - - Frozen by design — consumers must use model_copy(update={...}) - to create modified copies. - - Attributes: - request: Execution environment, request ID, timestamp, tracing (immutable). - agent: Session tracking, multi-agent lineage, user intent (immutable). - http: HTTP headers with capability-gated access (guarded). - security: Labels, classification, identity, access control, data policy (monotonic/immutable). - mcp: Tool, resource, or prompt metadata (immutable). - completion: Stop reason, token usage, model, latency (immutable). - provenance: Source, message ID, parent ID (immutable). - llm: Model identity and capabilities (immutable). - framework: Agentic framework context (immutable). - meta: Host-provided operational metadata — tags, scope, properties (immutable). - custom: Custom extensions (mutable). - - Examples: - >>> ext = Extensions( - ... request=RequestExtension( - ... environment="production", - ... request_id="req-001", - ... ), - ... llm=LLMExtension( - ... model_id="gpt-4o", - ... provider="openai", - ... ), - ... ) - >>> ext.request.environment - 'production' - >>> ext.llm.provider - 'openai' - >>> ext.security is None - True - - >>> # Frozen: modifications require model_copy - >>> updated = ext.model_copy(update={"custom": {"trace": True}}) - >>> updated.custom - {'trace': True} - >>> ext.custom is None - True - """ - - model_config = ConfigDict(frozen=True) - - request: RequestExtension | None = Field(default=None, description="Execution environment and tracing.") - agent: AgentExtension | None = Field(default=None, description="Agent execution context.") - http: HttpExtension | None = Field(default=None, description="HTTP request context.") - security: SecurityExtension | None = Field(default=None, description="Security labels and identity.") - delegation: DelegationExtension | None = Field(default=None, description="Delegation chain state.") - mcp: MCPExtension | None = Field(default=None, description="MCP entity metadata.") - completion: CompletionExtension | None = Field(default=None, description="LLM completion information.") - provenance: ProvenanceExtension | None = Field(default=None, description="Origin and threading.") - llm: LLMExtension | None = Field(default=None, description="Model identity and capabilities.") - framework: FrameworkExtension | None = Field(default=None, description="Agentic framework context.") - meta: MetaExtension | None = Field(default=None, description="Host-provided operational metadata.") - custom: dict[str, Any] | None = Field(default=None, description="Custom extensions (mutable).") diff --git a/cpex/framework/extensions/framework.py b/cpex/framework/extensions/framework.py deleted file mode 100644 index 13d269bd..00000000 --- a/cpex/framework/extensions/framework.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/framework.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Framework extension model. -Captures the agentic framework execution environment for messages -originating from or passing through orchestration layers. -Immutable tier — shared reference, no modifications allowed. -""" - -# Standard -from typing import Any - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class FrameworkExtension(BaseModel): - """Agentic framework execution context. - - Captures framework-level metadata for messages that originate - from or pass through agentic orchestration layers (LangGraph, - CrewAI, AutoGen, A2A, etc.). Immutable — the processing pipeline - rejects any modifications. - - Attributes: - framework: Framework identifier (e.g., langgraph, crewai, autogen, a2a). - framework_version: Framework version. - node_id: Framework-specific node or step identifier. - graph_id: Graph or workflow identifier. - metadata: Framework-specific metadata. - - Examples: - >>> ext = FrameworkExtension( - ... framework="langgraph", - ... framework_version="0.2.0", - ... node_id="weather_node", - ... graph_id="travel_planner", - ... ) - >>> ext.framework - 'langgraph' - >>> ext.node_id - 'weather_node' - """ - - model_config = ConfigDict(frozen=True) - - framework: str | None = Field(default=None, description="Framework identifier.") - framework_version: str | None = Field(default=None, description="Framework version.") - node_id: str | None = Field(default=None, description="Framework-specific node or step identifier.") - graph_id: str | None = Field(default=None, description="Graph or workflow identifier.") - metadata: dict[str, Any] = Field(default_factory=dict, description="Framework-specific metadata.") diff --git a/cpex/framework/extensions/http.py b/cpex/framework/extensions/http.py deleted file mode 100644 index f7a9458e..00000000 --- a/cpex/framework/extensions/http.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/http.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -HTTP extension model. -Carries HTTP request context with capability-gated access. -Guarded tier — readable with read_headers, writable with write_headers. -""" - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class HttpExtension(BaseModel): - """HTTP request context. - - Readable with the read_headers capability, writable with - write_headers. Sensitive headers (Authorization, Cookie, X-API-Key) - are stripped when serialized for external policy engines. - - Guarded tier — the processing pipeline rejects modifications - unless the consumer holds the write_headers capability. - - Attributes: - headers: HTTP headers as key-value pairs. - - Examples: - >>> ext = HttpExtension( - ... headers={"Content-Type": "application/json", "X-Request-ID": "req-123"}, - ... ) - >>> ext.headers["Content-Type"] - 'application/json' - - >>> # Frozen: modifications require model_copy - >>> updated = ext.model_copy( - ... update={"headers": {**ext.headers, "X-Trace-ID": "trace-456"}}, - ... ) - >>> "X-Trace-ID" in updated.headers - True - """ - - model_config = ConfigDict(frozen=True) - - headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers as key-value pairs.") diff --git a/cpex/framework/extensions/llm.py b/cpex/framework/extensions/llm.py deleted file mode 100644 index cfcddd24..00000000 --- a/cpex/framework/extensions/llm.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/llm.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -LLM extension model. -Carries model identity and capability metadata for routing, -policy evaluation, and audit. -Immutable tier — shared reference, no modifications allowed. -""" - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class LLMExtension(BaseModel): - """Model identity and capability metadata. - - Used for routing, policy evaluation, and audit when the producing - model's identity matters independently of the completion itself. - Immutable — the processing pipeline rejects any modifications. - - Attributes: - model_id: Model identifier (e.g., gpt-4o, claude-sonnet-4-20250514). - provider: Provider name (e.g., openai, anthropic, google). - capabilities: Declared model capabilities (e.g., vision, tool_use, extended_thinking). - - Examples: - >>> ext = LLMExtension( - ... model_id="claude-sonnet-4-20250514", - ... provider="anthropic", - ... capabilities=["vision", "tool_use", "extended_thinking"], - ... ) - >>> ext.provider - 'anthropic' - >>> "tool_use" in ext.capabilities - True - """ - - model_config = ConfigDict(frozen=True) - - model_id: str | None = Field(default=None, description="Model identifier.") - provider: str | None = Field(default=None, description="Provider name.") - capabilities: list[str] = Field(default_factory=list, description="Declared model capabilities.") diff --git a/cpex/framework/extensions/mcp.py b/cpex/framework/extensions/mcp.py deleted file mode 100644 index 11463751..00000000 --- a/cpex/framework/extensions/mcp.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/mcp.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -MCP extension models. -Carries typed metadata about MCP entities (tools, resources, prompts) -being processed. Gives consumers access to schemas and annotations. -Immutable tier — shared reference, no modifications allowed. -""" - -# Standard -from typing import Any - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class ToolMetadata(BaseModel): - """Typed metadata for an MCP tool. - - Attributes: - name: Unique tool identifier. - title: Human-readable display name. - description: Description of tool functionality. - input_schema: JSON Schema defining expected parameters. - output_schema: JSON Schema for structured output. - server_id: ID of the server providing this tool. - namespace: Tool namespace (server/origin). - annotations: MCP annotations (e.g., readOnlyHint, destructiveHint). - - Examples: - >>> meta = ToolMetadata( - ... name="get_user", - ... description="Retrieve user by ID", - ... input_schema={"type": "object", "properties": {"id": {"type": "string"}}}, - ... server_id="user-service", - ... ) - >>> meta.name - 'get_user' - >>> meta.server_id - 'user-service' - """ - - model_config = ConfigDict(frozen=True) - - name: str = Field(description="Unique tool identifier.") - title: str | None = Field(default=None, description="Human-readable display name.") - description: str | None = Field(default=None, description="Description of tool functionality.") - input_schema: dict[str, Any] | None = Field(default=None, description="JSON Schema defining expected parameters.") - output_schema: dict[str, Any] | None = Field(default=None, description="JSON Schema for structured output.") - server_id: str | None = Field(default=None, description="ID of the server providing this tool.") - namespace: str | None = Field(default=None, description="Tool namespace (server/origin).") - annotations: dict[str, Any] = Field(default_factory=dict, description="MCP annotations.") - - -class ResourceMetadata(BaseModel): - """Typed metadata for an MCP resource. - - Attributes: - uri: Resource URI. - name: Resource name. - description: Resource description. - mime_type: MIME type (text/csv, application/json, etc.). - server_id: ID of the server providing this resource. - annotations: MCP annotations (classification, retention, access hints). - - Examples: - >>> meta = ResourceMetadata( - ... uri="file:///data/report.csv", - ... name="Quarterly Report", - ... mime_type="text/csv", - ... ) - >>> meta.uri - 'file:///data/report.csv' - """ - - model_config = ConfigDict(frozen=True) - - uri: str = Field(description="Resource URI.") - name: str | None = Field(default=None, description="Resource name.") - description: str | None = Field(default=None, description="Resource description.") - mime_type: str | None = Field(default=None, description="MIME type.") - server_id: str | None = Field(default=None, description="ID of the server providing this resource.") - annotations: dict[str, Any] = Field(default_factory=dict, description="MCP annotations.") - - -class PromptMetadata(BaseModel): - """Typed metadata for an MCP prompt template. - - 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. - - Attributes: - name: Prompt template name. - description: Prompt description. - arguments: Argument definitions (each has name, description, required). - server_id: ID of the server providing this prompt. - annotations: MCP annotations. - - Examples: - >>> meta = PromptMetadata( - ... name="summarize", - ... description="Summarize a document", - ... arguments=[ - ... {"name": "text", "description": "Text to summarize", "required": True}, - ... ], - ... ) - >>> meta.name - 'summarize' - >>> meta.arguments[0]["name"] - 'text' - """ - - model_config = ConfigDict(frozen=True) - - name: str = Field(description="Prompt template name.") - description: str | None = Field(default=None, description="Prompt description.") - arguments: list[dict[str, Any]] | None = Field(default=None, description="Argument definitions.") - server_id: str | None = Field(default=None, description="ID of the server providing this prompt.") - annotations: dict[str, Any] = Field(default_factory=dict, description="MCP annotations.") - - -class MCPExtension(BaseModel): - """Typed metadata about the MCP entity being processed. - - Exactly one of tool, resource, or prompt is populated per message, - depending on the content type. Immutable — the processing pipeline - rejects any modifications. - - Attributes: - tool: Tool metadata (populated for tool_call / tool_result content). - resource: Resource metadata (populated for resource / resource_ref content). - prompt: Prompt metadata (populated for prompt_request / prompt_result content). - - Examples: - >>> ext = MCPExtension( - ... tool=ToolMetadata(name="get_user", description="Retrieve user by ID"), - ... ) - >>> ext.tool.name - 'get_user' - >>> ext.resource is None - True - """ - - model_config = ConfigDict(frozen=True) - - tool: ToolMetadata | None = Field(default=None, description="Tool metadata.") - resource: ResourceMetadata | None = Field(default=None, description="Resource metadata.") - prompt: PromptMetadata | None = Field(default=None, description="Prompt metadata.") diff --git a/cpex/framework/extensions/meta.py b/cpex/framework/extensions/meta.py deleted file mode 100644 index 88c53a49..00000000 --- a/cpex/framework/extensions/meta.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/meta.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Meta extension model. -Host-provided operational metadata about the entity being processed. -Set by the host system (gateway registration, static config, MCP manifest) -before the plugin pipeline runs. Immutable tier — plugins can read -this data for routing and policy decisions but cannot modify it. - -Protocol-agnostic: carries the same structure regardless of whether the -entity came from MCP, A2A, gRPC, or REST. -""" - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class MetaExtension(BaseModel): - """Host-provided operational metadata about the entity being processed. - - Tags drive route matching and policy group inheritance. Scope provides - a host-defined grouping (e.g., virtual server ID, namespace). Properties - carry arbitrary key-value metadata available in policy conditions. - - Immutable — the processing pipeline rejects any modifications. - Tags are set by the host and static config before the pipeline runs. - For pipeline-accumulated labels, use ``SecurityExtension.labels`` instead. - - Attributes: - tags: Entity tags (e.g., ``pii``, ``hr``, ``external-comms``). - Merged from static config and host-injected runtime tags. - scope: Host-defined grouping. ContextForge maps this to virtual - server ID, Kagenti to namespace, etc. CPEX core treats it - as an opaque string for matching. - properties: Arbitrary key-value metadata (e.g., ``owner``, - ``region``, ``data_classification``). Available in policy - conditions as ``meta.properties.{key}``. - - Examples: - >>> ext = MetaExtension( - ... tags=frozenset({"pii", "hr"}), - ... scope="hr-services", - ... properties={"owner": "hr-team", "data_classification": "confidential"}, - ... ) - >>> "pii" in ext.tags - True - >>> ext.scope - 'hr-services' - >>> ext.properties["owner"] - 'hr-team' - """ - - model_config = ConfigDict(frozen=True) - - tags: frozenset[str] = Field( - default_factory=frozenset, description="Entity tags for routing and policy group inheritance." - ) - scope: str | None = Field(default=None, description="Host-defined grouping (opaque string for matching).") - properties: dict[str, str] = Field(default_factory=dict, description="Arbitrary key-value metadata.") diff --git a/cpex/framework/extensions/provenance.py b/cpex/framework/extensions/provenance.py deleted file mode 100644 index f9a5e46e..00000000 --- a/cpex/framework/extensions/provenance.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/provenance.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Provenance extension model. -Carries origin and threading information for lineage tracking -across multi-turn conversations and multi-agent systems. -Immutable tier — shared reference, no modifications allowed. -""" - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class ProvenanceExtension(BaseModel): - """Origin and threading information for the message. - - Enables lineage tracking across multi-turn conversations and - multi-agent systems. Immutable — the processing pipeline rejects - any modifications. - - Attributes: - source: Source identifier (e.g., "user", "agent:xyz", "mcp-server:abc"). - message_id: Unique message identifier. - parent_id: Parent message ID (threading/replies). - - Examples: - >>> ext = ProvenanceExtension( - ... source="agent:weather-bot", - ... message_id="msg-001", - ... parent_id="msg-000", - ... ) - >>> ext.source - 'agent:weather-bot' - >>> ext.message_id - 'msg-001' - """ - - model_config = ConfigDict(frozen=True) - - source: str | None = Field(default=None, description="Source identifier.") - message_id: str | None = Field(default=None, description="Unique message identifier.") - parent_id: str | None = Field(default=None, description="Parent message ID (threading/replies).") diff --git a/cpex/framework/extensions/request.py b/cpex/framework/extensions/request.py deleted file mode 100644 index a1fa48c2..00000000 --- a/cpex/framework/extensions/request.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/request.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Request extension model. -Carries execution environment and request-level timing/tracing metadata. -Immutable tier — shared reference, no modifications allowed. -""" - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - - -class RequestExtension(BaseModel): - """Execution environment and request-level timing/tracing. - - Available to all consumers without any capability requirement (base tier). - Immutable — the processing pipeline rejects any modifications. - - Attributes: - environment: Execution environment (production, staging, dev). - request_id: Request correlation ID. - timestamp: ISO timestamp of the request. - trace_id: Distributed tracing ID (OpenTelemetry). - span_id: Distributed tracing span ID. - - Examples: - >>> ext = RequestExtension( - ... environment="production", - ... request_id="req-abc-123", - ... timestamp="2025-01-15T10:30:00Z", - ... ) - >>> ext.environment - 'production' - >>> ext.request_id - 'req-abc-123' - - >>> # Frozen: modifications require model_copy - >>> updated = ext.model_copy(update={"span_id": "span-456"}) - >>> updated.span_id - 'span-456' - >>> ext.span_id is None - True - """ - - model_config = ConfigDict(frozen=True) - - environment: str | None = Field(default=None, description="Execution environment (production, staging, dev).") - request_id: str | None = Field(default=None, description="Request correlation ID.") - timestamp: str | None = Field(default=None, description="ISO timestamp of the request.") - trace_id: str | None = Field(default=None, description="Distributed tracing ID (OpenTelemetry).") - span_id: str | None = Field(default=None, description="Distributed tracing span ID.") diff --git a/cpex/framework/extensions/security.py b/cpex/framework/extensions/security.py deleted file mode 100644 index f13e7d5b..00000000 --- a/cpex/framework/extensions/security.py +++ /dev/null @@ -1,247 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/security.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Security extension models. -Carries data classification, security labels, authenticated identity, -access control profiles, and data governance policies. - -The SecurityExtension itself is monotonic tier — labels can only be -added, never removed, during normal message flow. Its nested fields -(subject, objects, data) are immutable tier. -""" - -# Standard -from enum import Enum - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field - -# --------------------------------------------------------------------------- -# Subject -# --------------------------------------------------------------------------- - - -class SubjectType(str, Enum): - """Closed-set enumeration of subject types. - - Attributes: - USER: Human user. - AGENT: Autonomous agent. - SERVICE: Backend service. - SYSTEM: System-level principal. - - Examples: - >>> SubjectType.USER - - >>> SubjectType("agent") - - """ - - USER = "user" - AGENT = "agent" - SERVICE = "service" - SYSTEM = "system" - - -class SubjectExtension(BaseModel): - """Authenticated entity making the request. - - Access to individual fields is controlled by declared capabilities - on the MessageView. Immutable — the processing pipeline rejects - any modifications. - - Attributes: - id: Unique subject identifier. - type: Subject kind. - roles: Assigned roles (developer, admin, viewer, etc.). - permissions: Granted permissions (tools.execute, db.read, etc.). - teams: Team memberships (for multi-tenant scoping). - claims: Raw identity claims (JWT, SAML). - - Examples: - >>> subject = SubjectExtension( - ... id="user-alice", - ... type=SubjectType.USER, - ... roles={"admin", "developer"}, - ... permissions={"tools.execute", "db.read"}, - ... ) - >>> subject.id - 'user-alice' - >>> "admin" in subject.roles - True - >>> "db.read" in subject.permissions - True - """ - - model_config = ConfigDict(frozen=True) - - id: str = Field(description="Unique subject identifier.") - type: SubjectType = Field(description="Subject kind.") - roles: frozenset[str] = Field(default_factory=frozenset, description="Assigned roles.") - permissions: frozenset[str] = Field(default_factory=frozenset, description="Granted permissions.") - teams: frozenset[str] = Field(default_factory=frozenset, description="Team memberships.") - claims: dict[str, str] = Field(default_factory=dict, description="Raw identity claims (JWT, SAML).") - - -# --------------------------------------------------------------------------- -# Object Security Profile -# --------------------------------------------------------------------------- - - -class ObjectSecurityProfile(BaseModel): - """Access control contract declared by or for an object. - - Lives on extensions.security.objects, keyed by entity name/URI. - Evaluated on pre-hook views (tool_call, resource request, prompt - request). Immutable — the processing pipeline rejects any - modifications. - - Attributes: - managed_by: Who enforces access control: host, tool, or both. - permissions: Required permissions to invoke. - trust_domain: Trust domain: internal, external, or privileged. - data_scope: Field names this entity accesses/returns. - - Examples: - >>> profile = ObjectSecurityProfile( - ... managed_by="tool", - ... permissions=["read:compensation"], - ... trust_domain="internal", - ... data_scope=["salary", "bonus"], - ... ) - >>> profile.managed_by - 'tool' - >>> "read:compensation" in profile.permissions - True - """ - - model_config = ConfigDict(frozen=True) - - managed_by: str = Field(default="host", description="Who enforces access control: host, tool, or both.") - permissions: list[str] = Field(default_factory=list, description="Required permissions to invoke.") - trust_domain: str | None = Field(default=None, description="Trust domain: internal, external, or privileged.") - data_scope: list[str] = Field(default_factory=list, description="Field names this entity accesses/returns.") - - -# --------------------------------------------------------------------------- -# Data Policy -# --------------------------------------------------------------------------- - - -class RetentionPolicy(BaseModel): - """Data retention constraints. - - Attributes: - max_age_seconds: Maximum retention duration in seconds. - policy: Retention class: session, transient, persistent, or none. - delete_after: ISO timestamp after which data must be deleted. - - Examples: - >>> ret = RetentionPolicy(policy="session", max_age_seconds=3600) - >>> ret.policy - 'session' - >>> ret.max_age_seconds - 3600 - """ - - model_config = ConfigDict(frozen=True) - - max_age_seconds: int | None = Field(default=None, description="Maximum retention duration in seconds.") - policy: str = Field(default="persistent", description="Retention class: session, transient, persistent, none.") - delete_after: str | None = Field(default=None, description="ISO timestamp after which data must be deleted.") - - -class DataPolicy(BaseModel): - """Data governance policy for data returned by an entity. - - Lives on extensions.security.data, keyed by entity name/URI. - Enforced on post-hook views (tool_result, resource response, - prompt result). Always enforced by the gateway — the tool - declares, the framework enforces. Immutable — the processing - pipeline rejects any modifications. - - Attributes: - apply_labels: Labels to stamp on output (PII, financial, etc.). - allowed_actions: What downstream can do. None means unrestricted. - denied_actions: What downstream cannot do (export, forward, log_raw). - retention: How long data can be kept. - - Examples: - >>> policy = DataPolicy( - ... apply_labels=["PII", "financial"], - ... denied_actions=["export", "forward", "log_raw"], - ... retention=RetentionPolicy(policy="session", max_age_seconds=7200), - ... ) - >>> "PII" in policy.apply_labels - True - >>> policy.retention.policy - 'session' - """ - - model_config = ConfigDict(frozen=True) - - apply_labels: list[str] = Field(default_factory=list, description="Labels to stamp on output.") - allowed_actions: list[str] | None = Field( - default=None, description="What downstream can do. None means unrestricted." - ) - denied_actions: list[str] = Field(default_factory=list, description="What downstream cannot do.") - retention: RetentionPolicy | None = Field(default=None, description="How long data can be kept.") - - -# --------------------------------------------------------------------------- -# SecurityExtension -# --------------------------------------------------------------------------- - - -class SecurityExtension(BaseModel): - """Data classification, security labels, and security-relevant context. - - Monotonic tier for labels — labels can only be added, never removed, - during normal message flow. Removal requires a privileged - declassification operation that is audited separately. The nested - fields (subject, objects, data) are immutable. - - Attributes: - labels: Security/data labels (PII, CONFIDENTIAL, SECRET, etc.). - classification: Data classification level. - subject: Authenticated identity. - objects: Access control profiles, keyed by entity identifier. - data: Data governance policies, keyed by entity identifier. - - Examples: - >>> ext = SecurityExtension( - ... labels=frozenset({"PII", "CONFIDENTIAL"}), - ... classification="confidential", - ... subject=SubjectExtension( - ... id="user-alice", - ... type=SubjectType.USER, - ... roles=frozenset({"admin"}), - ... ), - ... ) - >>> "PII" in ext.labels - True - >>> ext.subject.id - 'user-alice' - - >>> # Monotonic label addition via model_copy - >>> updated = ext.model_copy(update={"labels": ext.labels | frozenset({"financial"})}) - >>> "financial" in updated.labels - True - >>> "PII" in updated.labels - True - """ - - model_config = ConfigDict(frozen=True) - - labels: frozenset[str] = Field(default_factory=frozenset, description="Security/data labels.") - classification: str | None = Field(default=None, description="Data classification level.") - subject: SubjectExtension | None = Field(default=None, description="Authenticated identity.") - objects: dict[str, ObjectSecurityProfile] = Field( - default_factory=dict, description="Access control profiles, keyed by entity identifier." - ) - data: dict[str, DataPolicy] = Field( - default_factory=dict, description="Data governance policies, keyed by entity identifier." - ) diff --git a/cpex/framework/extensions/tiers.py b/cpex/framework/extensions/tiers.py deleted file mode 100644 index 3fc88469..00000000 --- a/cpex/framework/extensions/tiers.py +++ /dev/null @@ -1,653 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/extensions/tiers.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Extension mutability tiers and capability-gated access. - -Defines the three mutability tiers (immutable, monotonic, mutable), -the capability enum for gating extension visibility and writability, -and the slot registry that maps each extension slot to its policy. - -Provides filter_extensions() for pre-hook capability filtering and -validate_tier_constraints() for post-hook tier enforcement. -""" - -# Standard -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from types import MappingProxyType -from typing import Any, Mapping - -from cpex.framework.extensions.constants import ( - FIELD_AGENT, - FIELD_CLAIMS, - FIELD_CLASSIFICATION, - FIELD_COMPLETION, - FIELD_CUSTOM, - FIELD_DATA, - FIELD_DELEGATION, - FIELD_FRAMEWORK, - FIELD_HTTP, - FIELD_LABELS, - FIELD_LLM, - FIELD_MCP, - FIELD_META, - FIELD_OBJECTS, - FIELD_PERMISSIONS, - FIELD_PROVENANCE, - FIELD_REQUEST, - FIELD_ROLES, - FIELD_SECURITY, - FIELD_SUBJECT, - FIELD_TEAMS, - SlotName, -) -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.security import SecurityExtension, SubjectExtension - - -class MutabilityTier(str, Enum): - """Mutability tier for an extension slot. - - Attributes: - IMMUTABLE: Set once, never changed. Pipeline rejects any delta. - MONOTONIC: Can only grow (add elements). Pipeline validates - before <= after. - MUTABLE: Freely modifiable through COW. - """ - - IMMUTABLE = "immutable" - MONOTONIC = "monotonic" - MUTABLE = "mutable" - - -class Capability(str, Enum): - """Declared capabilities that a plugin can request. - - Controls visibility (read) and writability (write/append) of - extension slots. Write/append capabilities imply their - corresponding read capability. - - Attributes: - READ_SUBJECT: Access to subject.id and subject.type. - READ_ROLES: Access to subject.roles. - READ_TEAMS: Access to subject.teams. - READ_CLAIMS: Access to subject.claims. - READ_PERMISSIONS: Access to subject.permissions. - READ_AGENT: Access to AgentExtension. - READ_HEADERS: Read access to HTTP headers. - WRITE_HEADERS: Read + write access to HTTP headers. - READ_LABELS: Read access to security labels. - APPEND_LABELS: Read + append-only access to security labels. - READ_LABELS: Read access to security labels. - APPEND_LABELS: Read + append-only access to security labels. - READ_DELEGATION: Read access to DelegationExtension (chain, depth, origin, actor). - APPEND_DELEGATION: Read + append-only access to the delegation chain. - """ - - READ_SUBJECT = "read_subject" - READ_ROLES = "read_roles" - READ_TEAMS = "read_teams" - READ_CLAIMS = "read_claims" - READ_PERMISSIONS = "read_permissions" - READ_AGENT = "read_agent" - READ_HEADERS = "read_headers" - WRITE_HEADERS = "write_headers" - READ_LABELS = "read_labels" - APPEND_LABELS = "append_labels" - READ_DELEGATION = "read_delegation" - APPEND_DELEGATION = "append_delegation" - - -# Write/append capabilities that imply their read counterpart. -_WRITE_IMPLIES_READ: dict[Capability, Capability] = { - Capability.WRITE_HEADERS: Capability.READ_HEADERS, - Capability.APPEND_LABELS: Capability.READ_LABELS, - Capability.APPEND_DELEGATION: Capability.READ_DELEGATION, -} - -# Subject sub-field capabilities that imply read_subject. -_SUBJECT_IMPLIES_READ: frozenset[Capability] = frozenset( - { - Capability.READ_ROLES, - Capability.READ_TEAMS, - Capability.READ_CLAIMS, - Capability.READ_PERMISSIONS, - } -) - - -class AccessPolicy(str, Enum): - """Declares whether an extension slot requires capabilities for visibility. - - Attributes: - UNRESTRICTED: Visible to all plugins regardless of capabilities. - CAPABILITY_GATED: Requires a declared capability for visibility. - """ - - UNRESTRICTED = "unrestricted" - CAPABILITY_GATED = "capability_gated" - - -@dataclass(frozen=True) -class SlotPolicy: - """Policy for a single extension slot or sub-field. - - Attributes: - tier: The mutability tier. - access: Whether the slot is unrestricted or capability-gated. - read_cap: Capability required to see this slot (when capability-gated). - write_cap: Capability required to modify this slot. - None means no mutation path exists. - """ - - tier: MutabilityTier - access: AccessPolicy = AccessPolicy.UNRESTRICTED - read_cap: Capability | None = None - write_cap: Capability | None = None - - -# --------------------------------------------------------------------------- -# Slot Registry — single source of truth (internal only) -# --------------------------------------------------------------------------- - -_SLOT_REGISTRY: dict[str, SlotPolicy] = { - # Unrestricted — always visible, always immutable - SlotName.REQUEST: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.PROVENANCE: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.COMPLETION: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.LLM: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.FRAMEWORK: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.MCP: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.META: SlotPolicy(MutabilityTier.IMMUTABLE), - # Capability-gated, immutable - SlotName.AGENT: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_AGENT, - ), - # Subject — granular sub-field gating - SlotName.SECURITY_SUBJECT: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_SUBJECT, - ), - SlotName.SECURITY_SUBJECT_ROLES: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_ROLES, - ), - SlotName.SECURITY_SUBJECT_TEAMS: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_TEAMS, - ), - SlotName.SECURITY_SUBJECT_CLAIMS: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_CLAIMS, - ), - SlotName.SECURITY_SUBJECT_PERMISSIONS: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_PERMISSIONS, - ), - # Unrestricted — always visible sub-fields - SlotName.SECURITY_OBJECTS: SlotPolicy(MutabilityTier.IMMUTABLE), - SlotName.SECURITY_DATA: SlotPolicy(MutabilityTier.IMMUTABLE), - # Security labels — monotonic, capability-gated - SlotName.SECURITY_LABELS: SlotPolicy( - MutabilityTier.MONOTONIC, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_LABELS, - write_cap=Capability.APPEND_LABELS, - ), - # HTTP — capability-gated, writable with write cap - SlotName.HTTP: SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_HEADERS, - write_cap=Capability.WRITE_HEADERS, - ), - # Delegation — monotonic (chain grows, never shrinks), capability-gated. - # Contains identity-adjacent information (subject IDs, audiences, scopes). - # Framework controls chain growth via with_new_hop(); merge validates - # monotonic growth (new chain must be superset of original). - SlotName.DELEGATION: SlotPolicy( - MutabilityTier.MONOTONIC, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_DELEGATION, - write_cap=Capability.APPEND_DELEGATION, - ), - # Unrestricted, mutable — no capability gate - SlotName.CUSTOM: SlotPolicy(MutabilityTier.MUTABLE), -} - -# Read-only view — prevents mutation even if imported directly -_slot_registry: Mapping[str, SlotPolicy] = MappingProxyType(_SLOT_REGISTRY) - - -def _has_read_access(policy: SlotPolicy, capabilities: frozenset[str]) -> bool: - """Check if a plugin has read access to a slot. - - A plugin has read access if: - - The slot has no read_cap (base tier, always visible), OR - - The plugin holds the read_cap, OR - - The plugin holds a write_cap that implies the read_cap, OR - - For subject sub-fields: any subject sub-field cap implies - read_subject. - """ - if policy.access == AccessPolicy.UNRESTRICTED: - return True - if policy.read_cap.value in capabilities: - return True - # Check if any held write cap implies this read cap - for write_cap, implied_read in _WRITE_IMPLIES_READ.items(): - if implied_read == policy.read_cap and write_cap.value in capabilities: - return True - # Check if any subject sub-field cap implies read_subject - if policy.read_cap == Capability.READ_SUBJECT: - for sub_cap in _SUBJECT_IMPLIES_READ: - if sub_cap.value in capabilities: - return True - return False - - -def _has_subject_access(capabilities: frozenset[str]) -> bool: - """Check if a plugin has any subject-related capability.""" - if Capability.READ_SUBJECT.value in capabilities: - return True - for sub_cap in _SUBJECT_IMPLIES_READ: - if sub_cap.value in capabilities: - return True - return False - - -# --------------------------------------------------------------------------- -# Extension Filtering -# --------------------------------------------------------------------------- - - -def _build_filtered_subject( - subject: SubjectExtension, - capabilities: frozenset[str], -) -> SubjectExtension: - """Build a filtered SubjectExtension containing only accessible fields. - - Always includes id and type (base subject access). Individual - sub-fields (roles, teams, claims, permissions) are only populated - if the plugin holds the corresponding capability. - """ - return subject.model_copy( - update={ - FIELD_ROLES: subject.roles if Capability.READ_ROLES.value in capabilities else frozenset(), - FIELD_TEAMS: subject.teams if Capability.READ_TEAMS.value in capabilities else frozenset(), - FIELD_CLAIMS: subject.claims if Capability.READ_CLAIMS.value in capabilities else {}, - FIELD_PERMISSIONS: ( - subject.permissions if Capability.READ_PERMISSIONS.value in capabilities else frozenset() - ), - } - ) - - -def _build_filtered_security( - sec: SecurityExtension, - capabilities: frozenset[str], -) -> SecurityExtension: - """Build a filtered SecurityExtension containing only accessible fields. - - Unrestricted sub-fields (objects, data, classification) are always - included. Capability-gated sub-fields (labels, subject) are only - populated if the plugin holds the required capability. - """ - fields: dict[str, Any] = { - # Unrestricted — always included - FIELD_OBJECTS: sec.objects, - FIELD_DATA: sec.data, - FIELD_CLASSIFICATION: sec.classification, - } - - # Labels — capability-gated - if _has_read_access(_slot_registry[SlotName.SECURITY_LABELS], capabilities): - fields[FIELD_LABELS] = sec.labels - else: - fields[FIELD_LABELS] = frozenset() - - # Subject — granular capability-gated - if sec.subject is not None and _has_subject_access(capabilities): - fields[FIELD_SUBJECT] = _build_filtered_subject(sec.subject, capabilities) - else: - fields[FIELD_SUBJECT] = None - - return sec.model_copy(update=fields) - - -def filter_extensions( - extensions: Extensions | None, - capabilities: frozenset[str], -) -> Extensions | None: - """Build a new Extensions containing only slots the plugin can access. - - Starts from an empty Extensions and copies in only the slots the - plugin has read access to. Slots not explicitly included are left - as None (the default). This is 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) are always included, while labels and - subject sub-fields are gated by their respective capabilities. - - Args: - extensions: The source Extensions model instance (or None). - capabilities: Plugin's declared capability strings. - - Returns: - A new frozen Extensions with only accessible slots populated, - or None if input was None. - """ - if extensions is None: - return None - - fields: dict[str, Any] = {} - - # Unrestricted top-level slots — always included when present - if extensions.request is not None: - fields[FIELD_REQUEST] = extensions.request - if extensions.provenance is not None: - fields[FIELD_PROVENANCE] = extensions.provenance - if extensions.completion is not None: - fields[FIELD_COMPLETION] = extensions.completion - if extensions.llm is not None: - fields[FIELD_LLM] = extensions.llm - if extensions.framework is not None: - fields[FIELD_FRAMEWORK] = extensions.framework - if extensions.mcp is not None: - fields[FIELD_MCP] = extensions.mcp - if extensions.meta is not None: - fields[FIELD_META] = extensions.meta - # Capability-gated: delegation - if extensions.delegation is not None: - if _has_read_access(_slot_registry[SlotName.DELEGATION], capabilities): - fields[FIELD_DELEGATION] = extensions.delegation - if extensions.custom is not None: - fields[FIELD_CUSTOM] = extensions.custom - - # Capability-gated top-level slots — included only with access - if extensions.agent is not None: - if _has_read_access(_slot_registry[SlotName.AGENT], capabilities): - fields[FIELD_AGENT] = extensions.agent - - if extensions.http is not None: - if _has_read_access(_slot_registry[SlotName.HTTP], capabilities): - fields[FIELD_HTTP] = extensions.http - - # Security — granular sub-field filtering - if extensions.security is not None: - fields[FIELD_SECURITY] = _build_filtered_security(extensions.security, capabilities) - - return Extensions(**fields) - - -# --------------------------------------------------------------------------- -# Tier Validation -# --------------------------------------------------------------------------- - - -class TierViolationError(Exception): - """Raised when a plugin violates a mutability tier constraint. - - Attributes: - plugin_name: Name of the offending plugin. - slot: The extension slot that was violated. - tier: The mutability tier of the slot. - detail: Description of the violation. - """ - - def __init__( - self, - plugin_name: str, - slot: str, - tier: MutabilityTier, - detail: str, - ) -> None: - """Initialise a tier violation error. - - Args: - plugin_name: Name of the offending plugin. - slot: The extension slot that was violated. - tier: The mutability tier of the slot. - detail: Description of the violation. - """ - self.plugin_name = plugin_name - self.slot = slot - self.tier = tier - self.detail = detail - super().__init__(f"Plugin '{plugin_name}' violated {tier.value} tier on '{slot}': {detail}") - - -def _resolve_slot(ext: Extensions | None, dot_path: str) -> Any: - """Resolve a dot-notation slot path to its value.""" - if ext is None: - return None - obj: Any = ext - for part in dot_path.split("."): - if obj is None: - return None - obj = getattr(obj, part, None) - return obj - - -def _is_monotonic_superset(before: Any, after: Any) -> bool: - """Check that after is a superset of before for monotonic validation.""" - if before is None or (isinstance(before, frozenset) and len(before) == 0): - return True - if after is None: - return isinstance(before, frozenset) and len(before) == 0 - if isinstance(before, frozenset) and isinstance(after, frozenset): - return before <= after - return before == after - - -def validate_tier_constraints( - before: Extensions | None, - after: Extensions | None, - capabilities: frozenset[str], - plugin_name: str, -) -> None: - """Validate that tier constraints were respected after a plugin transform. - - Compares the original (unfiltered) extensions against the modified - extensions. Raises TierViolationError on any violation. - - Args: - before: Original Extensions before plugin execution. - after: Extensions after plugin execution. - capabilities: Plugin's declared capability strings. - plugin_name: Name of the plugin (for error messages). - - Raises: - TierViolationError: If a tier constraint was violated. - """ - if before is None and after is None: - return - - for slot_name, policy in _slot_registry.items(): - before_val = _resolve_slot(before, slot_name) - after_val = _resolve_slot(after, slot_name) - - # No change — always fine - if before_val == after_val: - continue - - # Mutable tier with no write_cap — freely modifiable - if policy.tier == MutabilityTier.MUTABLE and policy.write_cap is None: - continue - - # Something changed — check if mutation is allowed - if policy.write_cap is None: - raise TierViolationError( - plugin_name, - slot_name, - policy.tier, - "slot has no write capability and cannot be modified", - ) - - if policy.write_cap.value not in capabilities: - raise TierViolationError( - plugin_name, - slot_name, - policy.tier, - f"plugin lacks '{policy.write_cap.value}' capability", - ) - - # Plugin has write capability — check tier-specific constraints - if policy.tier == MutabilityTier.MONOTONIC: - if not _is_monotonic_superset(before_val, after_val): - raise TierViolationError( - plugin_name, - slot_name, - policy.tier, - "monotonic slot had elements removed", - ) - - -# --------------------------------------------------------------------------- -# Selective Merge -# --------------------------------------------------------------------------- - - -def _merge_security( - original: SecurityExtension, - plugin_sec: SecurityExtension | None, - capabilities: frozenset[str], - plugin_name: str, -) -> SecurityExtension | None: - """Accept writable security changes back into the original. - - - subject, objects, data, classification: immutable — ignored. - - labels: monotonic — accepted only if the plugin holds - append_labels and the result is a superset of the original. - - Returns None if nothing changed (caller should skip the update). - """ - if plugin_sec is None: - return None - - # Labels — monotonic, capability-gated - if Capability.APPEND_LABELS.value in capabilities and plugin_sec.labels != original.labels: - if not _is_monotonic_superset(original.labels, plugin_sec.labels): - raise TierViolationError( - plugin_name, - SlotName.SECURITY_LABELS, - MutabilityTier.MONOTONIC, - "monotonic slot had elements removed", - ) - return original.model_copy(update={FIELD_LABELS: plugin_sec.labels}) - - return None - - -def merge_extensions( - original: Extensions | None, - plugin_output: Extensions | None, - capabilities: frozenset[str], - plugin_name: str, -) -> Extensions | None: - """Merge accepted plugin changes back into the original Extensions. - - Only writable slots are read from the plugin's output: - - - **Immutable** slots (request, provenance, agent, etc.) are - ignored — the original values are preserved. - - **Monotonic** slots (security.labels) are accepted only when - the plugin holds the write capability and the result is a - superset of the original. - - **Mutable** slots (custom) are accepted unconditionally. - - **Guarded-writable** slots (http) are accepted only when the - plugin holds the write capability. - - If nothing changed, the original object is returned as-is. - - This is the complement of ``filter_extensions`` (which controls - what a plugin *sees*). ``merge_extensions`` controls what the - manager *accepts back*. - - Args: - original: The authoritative Extensions before plugin execution. - plugin_output: The Extensions returned by the plugin. - capabilities: Plugin's declared capability strings. - plugin_name: Name of the plugin (for error messages). - - Returns: - The original Extensions with accepted changes applied via - model_copy, or the original unchanged if nothing was accepted. - - Raises: - TierViolationError: If a monotonic slot had elements removed. - """ - if original is None: - return None - if plugin_output is None: - return original - - updates: dict[str, Any] = {} - - # HTTP — writable only with write_headers capability - if ( - Capability.WRITE_HEADERS.value in capabilities - and plugin_output.http is not None - and plugin_output.http != original.http - ): - updates[FIELD_HTTP] = plugin_output.http - - # Security — mixed tiers, delegate to helper - if original.security is not None: - merged_sec = _merge_security(original.security, plugin_output.security, capabilities, plugin_name) - if merged_sec is not None: - updates[FIELD_SECURITY] = merged_sec - - # Delegation — monotonic (chain must grow, never shrink), requires append_delegation - if ( - Capability.APPEND_DELEGATION.value in capabilities - and plugin_output.delegation is not None - and original.delegation is not None - and plugin_output.delegation != original.delegation - ): - # Validate monotonic: new chain must be a superset (longer or equal, same prefix) - orig_chain = original.delegation.chain - new_chain = plugin_output.delegation.chain - if len(new_chain) < len(orig_chain): - raise TierViolationError( - plugin_name, - FIELD_DELEGATION, - MutabilityTier.MONOTONIC, - f"shrank delegation chain (was {len(orig_chain)} hops, now {len(new_chain)})", - ) - if new_chain[: len(orig_chain)] != orig_chain: - raise TierViolationError( - plugin_name, - FIELD_DELEGATION, - MutabilityTier.MONOTONIC, - "modified existing delegation chain hops", - ) - updates[FIELD_DELEGATION] = plugin_output.delegation - elif ( - Capability.APPEND_DELEGATION.value in capabilities - and plugin_output.delegation is not None - and original.delegation is None - ): - # First delegation — accept (requires append_delegation capability) - updates[FIELD_DELEGATION] = plugin_output.delegation - - # Custom — mutable, no capability gate - if plugin_output.custom != original.custom: - updates[FIELD_CUSTOM] = plugin_output.custom - - if not updates: - return original - - return original.model_copy(update=updates) diff --git a/cpex/framework/external/__init__.py b/cpex/framework/external/__init__.py deleted file mode 100644 index a68aefc8..00000000 --- a/cpex/framework/external/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -External plugin which connects to a remote server. -Module that contains plugin client/server code to serve external plugins. - -This package supports two transport mechanisms: -- MCP (Model Context Protocol): HTTP/SSE-based transport (default) -- gRPC: Binary protocol for higher performance - -Usage: - MCP Transport: - ```yaml - plugins: - - name: "MyPlugin" - kind: "external" - mcp: - proto: "STREAMABLEHTTP" - url: "http://localhost:8000/mcp" - ``` - - gRPC Transport: - ```yaml - plugins: - - name: "MyPlugin" - kind: "external" - grpc: - target: "localhost:50051" - ``` -""" - -# MCP transport exports (always available) -from cpex.framework.external.mcp.client import ExternalHookRef, ExternalPlugin - -__all__ = ["ExternalPlugin", "ExternalHookRef"] - -# gRPC transport exports (optional - requires grpc extras) -try: - from cpex.framework.external.grpc import GrpcExternalPlugin # noqa: E402, F401 - - __all__.extend(["GrpcExternalPlugin"]) -except ImportError: - # grpc extras not installed - pass diff --git a/cpex/framework/external/grpc/__init__.py b/cpex/framework/external/grpc/__init__.py deleted file mode 100644 index da5e9302..00000000 --- a/cpex/framework/external/grpc/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -gRPC transport for external plugins. - -This package provides gRPC-based communication between ContextForge and -external plugin servers. It offers a faster binary protocol alternative to -the MCP/HTTP transport while maintaining the same plugin semantics. - -Usage: - Client (Gateway side): - Configure a plugin with the `grpc:` section instead of `mcp:`: - - ```yaml - plugins: - - name: "MyPlugin" - kind: "external" - hooks: ["tool_pre_invoke"] - grpc: - target: "localhost:50051" - tls: - verify: true - ca_bundle: /path/to/ca.pem - ``` - - Server (Plugin side): - Run the gRPC server to expose your plugins: - - ```bash - python -m cpex.framework.external.grpc.server.runtime \\ - --config plugins/config.yaml \\ - --port 50051 - ``` - -Exports: - GrpcExternalPlugin: Client-side plugin that connects to gRPC server. - GrpcPluginServicer: Server-side gRPC servicer. - GrpcHealthServicer: Health check servicer. - create_client_credentials: Helper to create gRPC client TLS credentials. - create_server_credentials: Helper to create gRPC server TLS credentials. - -Note: - gRPC plugins use the existing ExternalHookRef from the MCP transport since - both transports share the same invoke_hook() interface. -""" - -from cpex.framework.external.grpc.client import GrpcExternalPlugin -from cpex.framework.external.grpc.tls_utils import create_client_credentials, create_server_credentials - -__all__ = [ - "GrpcExternalPlugin", - "create_client_credentials", - "create_server_credentials", -] - -# Server exports are imported lazily to avoid circular imports -# Import here after client to ensure proper initialization order -from cpex.framework.external.grpc.server import GrpcHealthServicer, GrpcPluginServicer # noqa: E402, F401 - -__all__.extend(["GrpcPluginServicer", "GrpcHealthServicer"]) diff --git a/cpex/framework/external/grpc/client.py b/cpex/framework/external/grpc/client.py deleted file mode 100644 index f5298e6c..00000000 --- a/cpex/framework/external/grpc/client.py +++ /dev/null @@ -1,286 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -External plugin client which connects to a remote server through gRPC. -Module that contains plugin gRPC client code to serve external plugins. -""" - -# pylint: disable=no-member,no-name-in-module - -# Standard -import asyncio -import logging -from typing import Optional - -import grpc - -# Third-Party -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Struct - -# First-Party -from cpex.framework.base import Plugin -from cpex.framework.constants import IGNORE_CONFIG_EXTERNAL -from cpex.framework.errors import PluginError, convert_exception_to_error -from cpex.framework.external.grpc.proto import plugin_service_pb2, plugin_service_pb2_grpc -from cpex.framework.external.grpc.tls_utils import create_insecure_channel, create_secure_channel -from cpex.framework.external.proto_convert import pydantic_context_to_proto, update_pydantic_context_from_proto -from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.models import ( - GRPCClientTLSConfig, - PluginConfig, - PluginContext, - PluginErrorModel, - PluginPayload, - PluginResult, -) - -logger = logging.getLogger(__name__) - - -class GrpcExternalPlugin(Plugin): - """External plugin object that connects to a remote gRPC server. - - This plugin implementation connects to a remote plugin server via gRPC, - providing a faster binary protocol alternative to the MCP transport. - - Examples: - >>> from cpex.framework.models import PluginConfig, GRPCClientConfig - >>> config = PluginConfig( - ... name="MyGrpcPlugin", - ... kind="external", - ... grpc=GRPCClientConfig(target="localhost:50051") - ... ) - >>> plugin = GrpcExternalPlugin(config) - >>> # await plugin.initialize() - """ - - def __init__(self, config: PluginConfig) -> None: - """Initialize a gRPC external plugin with a configuration. - - Args: - config: The plugin configuration containing gRPC connection details. - """ - super().__init__(config) - self._channel: Optional[grpc.aio.Channel] = None - self._stub: Optional[plugin_service_pb2_grpc.PluginServiceStub] = None - - async def initialize(self) -> None: - """Initialize the plugin's connection to the gRPC server. - - This method: - 1. Creates a gRPC channel (secure or insecure based on config) - 2. Creates the service stub - 3. Fetches remote plugin configuration - 4. Merges remote config with local config - - Raises: - PluginError: If unable to connect or retrieve plugin configuration. - """ - if not self._config.grpc: - raise PluginError( - error=PluginErrorModel( - message="The grpc section must be defined for gRPC external plugin", - plugin_name=self.name, - ) - ) - - target = self._config.grpc.get_target() - tls_config = self._config.grpc.tls or GRPCClientTLSConfig.from_env() - is_uds = self._config.grpc.uds is not None - - try: - # Create channel (TLS not supported for Unix domain sockets) - if tls_config and not is_uds: - self._channel = create_secure_channel(target, tls_config, self.name) - else: - self._channel = create_insecure_channel(target) - - # Create stub - self._stub = plugin_service_pb2_grpc.PluginServiceStub(self._channel) - - # Verify connection and get remote config - config = await self._get_plugin_config_with_retry() - - if not config: - raise PluginError( - error=PluginErrorModel( - message="Unable to retrieve configuration for external plugin", - plugin_name=self.name, - ) - ) - - # Merge remote config with local config (local takes precedence) - current_config = self._config.model_dump(exclude_unset=True) - remote_config = config.model_dump(exclude_unset=True) - remote_config.update(current_config) - - context = {IGNORE_CONFIG_EXTERNAL: True} - self._config = PluginConfig.model_validate(remote_config, context=context) - - logger.info("Successfully connected to gRPC plugin server at %s for plugin %s", target, self.name) - - except PluginError: - raise - except Exception as e: - logger.exception("Error connecting to gRPC plugin server: %s", e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - async def _get_plugin_config_with_retry( - self, max_retries: int = 3, base_delay: float = 1.0 - ) -> Optional[PluginConfig]: - """Retrieve plugin configuration with retry logic. - - Args: - max_retries: Maximum number of retry attempts. - base_delay: Base delay between retries (exponential backoff). - - Returns: - PluginConfig if successful, None otherwise. - - Raises: - PluginError: If all retries fail. - """ - for attempt in range(max_retries): - try: - return await self._get_plugin_config() - except Exception as e: - logger.warning("Connection attempt %d/%d failed: %s", attempt + 1, max_retries, e) - if attempt == max_retries - 1: - error_msg = f"gRPC plugin '{self.name}' connection failed after {max_retries} attempts" - raise PluginError(error=PluginErrorModel(message=error_msg, plugin_name=self.name)) - delay = base_delay * (2**attempt) - logger.info("Retrying in %ss...", delay) - await asyncio.sleep(delay) - - return None # pragma: no cover - - async def _get_plugin_config(self) -> Optional[PluginConfig]: - """Retrieve plugin configuration from the remote gRPC server. - - Returns: - PluginConfig if found, None otherwise. - - Raises: - PluginError: If there is a connection or validation error. - """ - if not self._stub: - raise PluginError( - error=PluginErrorModel( - message="gRPC stub not initialized", - plugin_name=self.name, - ) - ) - - try: - request = plugin_service_pb2.GetPluginConfigRequest(name=self.name) - response = await self._stub.GetPluginConfig(request) - - if response.found: - config_dict = json_format.MessageToDict(response.config) - return PluginConfig.model_validate(config_dict) - - return None - - except grpc.RpcError as e: - logger.error("gRPC error getting plugin config: %s", e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: - """Invoke an external plugin hook using gRPC. - - Args: - hook_type: The type of hook invoked (e.g., "tool_pre_invoke"). - payload: The payload to be passed to the hook. - context: The plugin context passed to the hook. - - Returns: - The resulting payload from the plugin. - - Raises: - PluginError: If there is an error invoking the hook. - """ - # Get the result type from the global registry - registry = get_hook_registry() - result_type = registry.get_result_type(hook_type) - if not result_type: - raise PluginError( - error=PluginErrorModel( - message=f"Hook type '{hook_type}' not registered in hook registry", - plugin_name=self.name, - ) - ) - - if not self._stub: - raise PluginError( - error=PluginErrorModel( - message="gRPC stub not initialized", - plugin_name=self.name, - ) - ) - - try: - # Convert payload to Struct (still polymorphic) - payload_struct = Struct() - json_format.ParseDict(payload.model_dump(), payload_struct) - - # Convert context to explicit proto message (faster than Struct) - context_proto = pydantic_context_to_proto(context) - - # Create and send request - request = plugin_service_pb2.InvokeHookRequest( - hook_type=hook_type, - plugin_name=self.name, - payload=payload_struct, - context=context_proto, - ) - - response = await self._stub.InvokeHook(request) - - # Check for error - if response.HasField("error") and response.error.message: - error = PluginErrorModel( - message=response.error.message, - plugin_name=response.error.plugin_name or self.name, - code=response.error.code, - mcp_error_code=response.error.mcp_error_code, - ) - if response.error.HasField("details"): - error.details = json_format.MessageToDict(response.error.details) - raise PluginError(error=error) - - # Update context if modified (using explicit proto message) - if response.HasField("context"): - update_pydantic_context_from_proto(context, response.context) - - # Parse and return result - if response.HasField("result"): - result_dict = json_format.MessageToDict(response.result) - return result_type.model_validate(result_dict) - - raise PluginError( - error=PluginErrorModel( - message="Received invalid response from gRPC plugin server", - plugin_name=self.name, - ) - ) - - except PluginError: - raise - except grpc.RpcError as e: - logger.exception("gRPC error invoking hook: %s", e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - except Exception as e: - logger.exception("Error invoking gRPC hook: %s", e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - async def shutdown(self) -> None: - """Shutdown the gRPC connection and cleanup resources.""" - if self._channel: - await self._channel.close() - self._channel = None - self._stub = None - logger.info("gRPC channel closed for plugin %s", self.name) diff --git a/cpex/framework/external/grpc/proto/__init__.py b/cpex/framework/external/grpc/proto/__init__.py deleted file mode 100644 index 2be31df4..00000000 --- a/cpex/framework/external/grpc/proto/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/proto/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Protocol buffer generated modules for gRPC plugin transport. - -This package contains the generated protobuf and gRPC stubs. -Run `make grpc-proto` to regenerate after modifying plugin_service.proto. -""" - -try: - from cpex.framework.external.grpc.proto import plugin_service_pb2, plugin_service_pb2_grpc - - __all__ = ["plugin_service_pb2", "plugin_service_pb2_grpc"] -except ImportError: - # Generated files may not exist yet - run `make grpc-proto` - pass diff --git a/cpex/framework/external/grpc/proto/plugin_service.proto b/cpex/framework/external/grpc/proto/plugin_service.proto deleted file mode 100644 index 4fd88fab..00000000 --- a/cpex/framework/external/grpc/proto/plugin_service.proto +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright 2025 -// SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor -// -// Protocol buffer definitions for gRPC external plugin transport. - -syntax = "proto3"; - -package cpex; - -import "google/protobuf/struct.proto"; - -// ============================================================================ -// Plugin Service - Main service for external plugin communication -// ============================================================================ - -service PluginService { - // Get configuration for a single plugin by name - rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse); - - // Get configurations for all plugins on the server - rpc GetPluginConfigs(GetPluginConfigsRequest) returns (GetPluginConfigsResponse); - - // Invoke a plugin hook - rpc InvokeHook(InvokeHookRequest) returns (InvokeHookResponse); -} - -// ============================================================================ -// Health Service - Standard gRPC health checking -// ============================================================================ - -service Health { - // Check the health status of the plugin server - rpc Check(HealthCheckRequest) returns (HealthCheckResponse); -} - -// ============================================================================ -// Request/Response Messages -// ============================================================================ - -// GetPluginConfig - Retrieve a single plugin's configuration -message GetPluginConfigRequest { - string name = 1; // Plugin name to retrieve -} - -message GetPluginConfigResponse { - bool found = 1; // Whether the plugin was found - google.protobuf.Struct config = 2; // Plugin configuration as JSON-like struct -} - -// GetPluginConfigs - Retrieve all plugin configurations -message GetPluginConfigsRequest { - // Empty - retrieves all plugins -} - -message GetPluginConfigsResponse { - repeated google.protobuf.Struct configs = 1; // List of plugin configurations -} - -// InvokeHook - Execute a plugin hook -message InvokeHookRequest { - string hook_type = 1; // Hook type identifier (e.g., "tool_pre_invoke") - string plugin_name = 2; // Name of the plugin to execute - google.protobuf.Struct payload = 3; // Hook payload (polymorphic, varies by hook_type) - PluginContext context = 4; // Plugin context (explicit message for performance) -} - -message InvokeHookResponse { - string plugin_name = 1; // Plugin that was executed - google.protobuf.Struct result = 2; // Full PluginResult as struct (polymorphic) - PluginContext context = 3; // Updated context (explicit message) - PluginError error = 4; // Error details (if failed) - PluginResultBase result_base = 5; // Common result fields (for fast access) -} - -// ============================================================================ -// Context Messages - Explicit messages for better serialization performance -// ============================================================================ - -// Global context shared across all plugins for a request -message GlobalContext { - string request_id = 1; // ID of the HTTP request - string server_id = 2; // Server ID - string tenant_id = 3; // Tenant ID - oneof user_value { // User can be string or struct - string user_string = 4; - google.protobuf.Struct user_struct = 5; - } - google.protobuf.Struct metadata = 6; // Read-only metadata - google.protobuf.Struct state = 7; // Shared state across plugins -} - -// Plugin context for a single request lifecycle -message PluginContext { - GlobalContext global_context = 1; // Global context shared across plugins - google.protobuf.Struct state = 2; // Plugin-local state - google.protobuf.Struct metadata = 3; // Plugin metadata -} - -// ============================================================================ -// Result Messages -// ============================================================================ - -// Policy violation from a plugin -message PluginViolation { - string reason = 1; // Short reason for the violation - string description = 2; // Longer description - string code = 3; // Violation code - string plugin_name = 4; // Plugin that generated the violation - google.protobuf.Struct details = 5; // Additional details - int32 mcp_error_code = 6; // MCP error code for client response -} - -// Common fields for all plugin results -message PluginResultBase { - bool continue_processing = 1; // Whether to continue plugin chain - PluginViolation violation = 2; // Violation if policy check failed - google.protobuf.Struct metadata = 3; // Result metadata -} - -// ============================================================================ -// Error and Status Messages -// ============================================================================ - -message PluginError { - string message = 1; // Error message - string plugin_name = 2; // Plugin that generated the error - string code = 3; // Error code - google.protobuf.Struct details = 4; // Additional error details - int32 mcp_error_code = 5; // MCP error code for client response -} - -// Health check messages (following gRPC health checking protocol) -message HealthCheckRequest { - string service = 1; // Service name (empty for overall health) -} - -message HealthCheckResponse { - enum ServingStatus { - UNKNOWN = 0; - SERVING = 1; - NOT_SERVING = 2; - SERVICE_UNKNOWN = 3; - } - ServingStatus status = 1; -} diff --git a/cpex/framework/external/grpc/proto/plugin_service_pb2.py b/cpex/framework/external/grpc/proto/plugin_service_pb2.py deleted file mode 100644 index cf589d81..00000000 --- a/cpex/framework/external/grpc/proto/plugin_service_pb2.py +++ /dev/null @@ -1,68 +0,0 @@ -# noqa: D100, D101, D102, D103, D104, D107, D400, D415 -# ruff: noqa -# type: ignore -# pylint: skip-file -# Generated by protoc - do not edit -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE -# source: plugin_service.proto -# Protobuf Python Version: 6.31.1 -"""Generated protocol buffer code.""" - -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder - -_runtime_version.ValidateProtobufRuntimeVersion(_runtime_version.Domain.PUBLIC, 6, 31, 1, "", "plugin_service.proto") -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x14plugin_service.proto\x12\x04\x63pex\x1a\x1cgoogle/protobuf/struct.proto"&\n\x16GetPluginConfigRequest\x12\x0c\n\x04name\x18\x01 \x01(\t"Q\n\x17GetPluginConfigResponse\x12\r\n\x05\x66ound\x18\x01 \x01(\x08\x12\'\n\x06\x63onfig\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct"\x19\n\x17GetPluginConfigsRequest"D\n\x18GetPluginConfigsResponse\x12(\n\x07\x63onfigs\x18\x01 \x03(\x0b\x32\x17.google.protobuf.Struct"\x8b\x01\n\x11InvokeHookRequest\x12\x11\n\thook_type\x18\x01 \x01(\t\x12\x13\n\x0bplugin_name\x18\x02 \x01(\t\x12(\n\x07payload\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12$\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x13.cpex.PluginContext"\xc7\x01\n\x12InvokeHookResponse\x12\x13\n\x0bplugin_name\x18\x01 \x01(\t\x12\'\n\x06result\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12$\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\x13.cpex.PluginContext\x12 \n\x05\x65rror\x18\x04 \x01(\x0b\x32\x11.cpex.PluginError\x12+\n\x0bresult_base\x18\x05 \x01(\x0b\x32\x16.cpex.PluginResultBase"\xf1\x01\n\rGlobalContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x11\n\tserver_id\x18\x02 \x01(\t\x12\x11\n\ttenant_id\x18\x03 \x01(\t\x12\x15\n\x0buser_string\x18\x04 \x01(\tH\x00\x12.\n\x0buser_struct\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x12)\n\x08metadata\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12&\n\x05state\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructB\x0c\n\nuser_value"\x8f\x01\n\rPluginContext\x12+\n\x0eglobal_context\x18\x01 \x01(\x0b\x32\x13.cpex.GlobalContext\x12&\n\x05state\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12)\n\x08metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct"\x9b\x01\n\x0fPluginViolation\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0c\n\x04\x63ode\x18\x03 \x01(\t\x12\x13\n\x0bplugin_name\x18\x04 \x01(\t\x12(\n\x07\x64\x65tails\x18\x05 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x16\n\x0emcp_error_code\x18\x06 \x01(\x05"\x84\x01\n\x10PluginResultBase\x12\x1b\n\x13\x63ontinue_processing\x18\x01 \x01(\x08\x12(\n\tviolation\x18\x02 \x01(\x0b\x32\x15.cpex.PluginViolation\x12)\n\x08metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct"\x83\x01\n\x0bPluginError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x13\n\x0bplugin_name\x18\x02 \x01(\t\x12\x0c\n\x04\x63ode\x18\x03 \x01(\t\x12(\n\x07\x64\x65tails\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x16\n\x0emcp_error_code\x18\x05 \x01(\x05"%\n\x12HealthCheckRequest\x12\x0f\n\x07service\x18\x01 \x01(\t"\x9f\x01\n\x13HealthCheckResponse\x12\x37\n\x06status\x18\x01 \x01(\x0e\x32\'.cpex.HealthCheckResponse.ServingStatus"O\n\rServingStatus\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07SERVING\x10\x01\x12\x0f\n\x0bNOT_SERVING\x10\x02\x12\x13\n\x0fSERVICE_UNKNOWN\x10\x03\x32\xf3\x01\n\rPluginService\x12N\n\x0fGetPluginConfig\x12\x1c.cpex.GetPluginConfigRequest\x1a\x1d.cpex.GetPluginConfigResponse\x12Q\n\x10GetPluginConfigs\x12\x1d.cpex.GetPluginConfigsRequest\x1a\x1e.cpex.GetPluginConfigsResponse\x12?\n\nInvokeHook\x12\x17.cpex.InvokeHookRequest\x1a\x18.cpex.InvokeHookResponse2F\n\x06Health\x12<\n\x05\x43heck\x12\x18.cpex.HealthCheckRequest\x1a\x19.cpex.HealthCheckResponseb\x06proto3' -) - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "plugin_service_pb2", _globals) -if not _descriptor._USE_C_DESCRIPTORS: - DESCRIPTOR._loaded_options = None - _globals["_GETPLUGINCONFIGREQUEST"]._serialized_start = 60 - _globals["_GETPLUGINCONFIGREQUEST"]._serialized_end = 98 - _globals["_GETPLUGINCONFIGRESPONSE"]._serialized_start = 100 - _globals["_GETPLUGINCONFIGRESPONSE"]._serialized_end = 181 - _globals["_GETPLUGINCONFIGSREQUEST"]._serialized_start = 183 - _globals["_GETPLUGINCONFIGSREQUEST"]._serialized_end = 208 - _globals["_GETPLUGINCONFIGSRESPONSE"]._serialized_start = 210 - _globals["_GETPLUGINCONFIGSRESPONSE"]._serialized_end = 278 - _globals["_INVOKEHOOKREQUEST"]._serialized_start = 281 - _globals["_INVOKEHOOKREQUEST"]._serialized_end = 420 - _globals["_INVOKEHOOKRESPONSE"]._serialized_start = 423 - _globals["_INVOKEHOOKRESPONSE"]._serialized_end = 622 - _globals["_GLOBALCONTEXT"]._serialized_start = 625 - _globals["_GLOBALCONTEXT"]._serialized_end = 866 - _globals["_PLUGINCONTEXT"]._serialized_start = 869 - _globals["_PLUGINCONTEXT"]._serialized_end = 1012 - _globals["_PLUGINVIOLATION"]._serialized_start = 1015 - _globals["_PLUGINVIOLATION"]._serialized_end = 1170 - _globals["_PLUGINRESULTBASE"]._serialized_start = 1173 - _globals["_PLUGINRESULTBASE"]._serialized_end = 1305 - _globals["_PLUGINERROR"]._serialized_start = 1308 - _globals["_PLUGINERROR"]._serialized_end = 1439 - _globals["_HEALTHCHECKREQUEST"]._serialized_start = 1441 - _globals["_HEALTHCHECKREQUEST"]._serialized_end = 1478 - _globals["_HEALTHCHECKRESPONSE"]._serialized_start = 1481 - _globals["_HEALTHCHECKRESPONSE"]._serialized_end = 1640 - _globals["_HEALTHCHECKRESPONSE_SERVINGSTATUS"]._serialized_start = 1561 - _globals["_HEALTHCHECKRESPONSE_SERVINGSTATUS"]._serialized_end = 1640 - _globals["_PLUGINSERVICE"]._serialized_start = 1643 - _globals["_PLUGINSERVICE"]._serialized_end = 1886 - _globals["_HEALTH"]._serialized_start = 1888 - _globals["_HEALTH"]._serialized_end = 1958 -# @@protoc_insertion_point(module_scope) diff --git a/cpex/framework/external/grpc/proto/plugin_service_pb2.pyi b/cpex/framework/external/grpc/proto/plugin_service_pb2.pyi deleted file mode 100644 index 3cb53192..00000000 --- a/cpex/framework/external/grpc/proto/plugin_service_pb2.pyi +++ /dev/null @@ -1,203 +0,0 @@ -# noqa: D100, D101, D102, D103, D104, D107, D400, D415 -# ruff: noqa -# type: ignore -# pylint: skip-file -# Generated by protoc - do not edit -from google.protobuf import struct_pb2 as _struct_pb2 -from google.protobuf.internal import containers as _containers -from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from collections.abc import Iterable as _Iterable, Mapping as _Mapping -from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union - -DESCRIPTOR: _descriptor.FileDescriptor - -class GetPluginConfigRequest(_message.Message): - __slots__ = ("name",) - NAME_FIELD_NUMBER: _ClassVar[int] - name: str - def __init__(self, name: _Optional[str] = ...) -> None: ... - -class GetPluginConfigResponse(_message.Message): - __slots__ = ("found", "config") - FOUND_FIELD_NUMBER: _ClassVar[int] - CONFIG_FIELD_NUMBER: _ClassVar[int] - found: bool - config: _struct_pb2.Struct - def __init__(self, found: bool = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... - -class GetPluginConfigsRequest(_message.Message): - __slots__ = () - def __init__(self) -> None: ... - -class GetPluginConfigsResponse(_message.Message): - __slots__ = ("configs",) - CONFIGS_FIELD_NUMBER: _ClassVar[int] - configs: _containers.RepeatedCompositeFieldContainer[_struct_pb2.Struct] - def __init__(self, configs: _Optional[_Iterable[_Union[_struct_pb2.Struct, _Mapping]]] = ...) -> None: ... - -class InvokeHookRequest(_message.Message): - __slots__ = ("hook_type", "plugin_name", "payload", "context") - HOOK_TYPE_FIELD_NUMBER: _ClassVar[int] - PLUGIN_NAME_FIELD_NUMBER: _ClassVar[int] - PAYLOAD_FIELD_NUMBER: _ClassVar[int] - CONTEXT_FIELD_NUMBER: _ClassVar[int] - hook_type: str - plugin_name: str - payload: _struct_pb2.Struct - context: PluginContext - def __init__( - self, - hook_type: _Optional[str] = ..., - plugin_name: _Optional[str] = ..., - payload: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - context: _Optional[_Union[PluginContext, _Mapping]] = ..., - ) -> None: ... - -class InvokeHookResponse(_message.Message): - __slots__ = ("plugin_name", "result", "context", "error", "result_base") - PLUGIN_NAME_FIELD_NUMBER: _ClassVar[int] - RESULT_FIELD_NUMBER: _ClassVar[int] - CONTEXT_FIELD_NUMBER: _ClassVar[int] - ERROR_FIELD_NUMBER: _ClassVar[int] - RESULT_BASE_FIELD_NUMBER: _ClassVar[int] - plugin_name: str - result: _struct_pb2.Struct - context: PluginContext - error: PluginError - result_base: PluginResultBase - def __init__( - self, - plugin_name: _Optional[str] = ..., - result: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - context: _Optional[_Union[PluginContext, _Mapping]] = ..., - error: _Optional[_Union[PluginError, _Mapping]] = ..., - result_base: _Optional[_Union[PluginResultBase, _Mapping]] = ..., - ) -> None: ... - -class GlobalContext(_message.Message): - __slots__ = ("request_id", "server_id", "tenant_id", "user_string", "user_struct", "metadata", "state") - REQUEST_ID_FIELD_NUMBER: _ClassVar[int] - SERVER_ID_FIELD_NUMBER: _ClassVar[int] - TENANT_ID_FIELD_NUMBER: _ClassVar[int] - USER_STRING_FIELD_NUMBER: _ClassVar[int] - USER_STRUCT_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - STATE_FIELD_NUMBER: _ClassVar[int] - request_id: str - server_id: str - tenant_id: str - user_string: str - user_struct: _struct_pb2.Struct - metadata: _struct_pb2.Struct - state: _struct_pb2.Struct - def __init__( - self, - request_id: _Optional[str] = ..., - server_id: _Optional[str] = ..., - tenant_id: _Optional[str] = ..., - user_string: _Optional[str] = ..., - user_struct: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - state: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - ) -> None: ... - -class PluginContext(_message.Message): - __slots__ = ("global_context", "state", "metadata") - GLOBAL_CONTEXT_FIELD_NUMBER: _ClassVar[int] - STATE_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - global_context: GlobalContext - state: _struct_pb2.Struct - metadata: _struct_pb2.Struct - def __init__( - self, - global_context: _Optional[_Union[GlobalContext, _Mapping]] = ..., - state: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - ) -> None: ... - -class PluginViolation(_message.Message): - __slots__ = ("reason", "description", "code", "plugin_name", "details", "mcp_error_code") - REASON_FIELD_NUMBER: _ClassVar[int] - DESCRIPTION_FIELD_NUMBER: _ClassVar[int] - CODE_FIELD_NUMBER: _ClassVar[int] - PLUGIN_NAME_FIELD_NUMBER: _ClassVar[int] - DETAILS_FIELD_NUMBER: _ClassVar[int] - MCP_ERROR_CODE_FIELD_NUMBER: _ClassVar[int] - reason: str - description: str - code: str - plugin_name: str - details: _struct_pb2.Struct - mcp_error_code: int - def __init__( - self, - reason: _Optional[str] = ..., - description: _Optional[str] = ..., - code: _Optional[str] = ..., - plugin_name: _Optional[str] = ..., - details: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - mcp_error_code: _Optional[int] = ..., - ) -> None: ... - -class PluginResultBase(_message.Message): - __slots__ = ("continue_processing", "violation", "metadata") - CONTINUE_PROCESSING_FIELD_NUMBER: _ClassVar[int] - VIOLATION_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - continue_processing: bool - violation: PluginViolation - metadata: _struct_pb2.Struct - def __init__( - self, - continue_processing: bool = ..., - violation: _Optional[_Union[PluginViolation, _Mapping]] = ..., - metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - ) -> None: ... - -class PluginError(_message.Message): - __slots__ = ("message", "plugin_name", "code", "details", "mcp_error_code") - MESSAGE_FIELD_NUMBER: _ClassVar[int] - PLUGIN_NAME_FIELD_NUMBER: _ClassVar[int] - CODE_FIELD_NUMBER: _ClassVar[int] - DETAILS_FIELD_NUMBER: _ClassVar[int] - MCP_ERROR_CODE_FIELD_NUMBER: _ClassVar[int] - message: str - plugin_name: str - code: str - details: _struct_pb2.Struct - mcp_error_code: int - def __init__( - self, - message: _Optional[str] = ..., - plugin_name: _Optional[str] = ..., - code: _Optional[str] = ..., - details: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., - mcp_error_code: _Optional[int] = ..., - ) -> None: ... - -class HealthCheckRequest(_message.Message): - __slots__ = ("service",) - SERVICE_FIELD_NUMBER: _ClassVar[int] - service: str - def __init__(self, service: _Optional[str] = ...) -> None: ... - -class HealthCheckResponse(_message.Message): - __slots__ = ("status",) - - class ServingStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - UNKNOWN: _ClassVar[HealthCheckResponse.ServingStatus] - SERVING: _ClassVar[HealthCheckResponse.ServingStatus] - NOT_SERVING: _ClassVar[HealthCheckResponse.ServingStatus] - SERVICE_UNKNOWN: _ClassVar[HealthCheckResponse.ServingStatus] - - UNKNOWN: HealthCheckResponse.ServingStatus - SERVING: HealthCheckResponse.ServingStatus - NOT_SERVING: HealthCheckResponse.ServingStatus - SERVICE_UNKNOWN: HealthCheckResponse.ServingStatus - STATUS_FIELD_NUMBER: _ClassVar[int] - status: HealthCheckResponse.ServingStatus - def __init__(self, status: _Optional[_Union[HealthCheckResponse.ServingStatus, str]] = ...) -> None: ... diff --git a/cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py b/cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py deleted file mode 100644 index e6cb957c..00000000 --- a/cpex/framework/external/grpc/proto/plugin_service_pb2_grpc.py +++ /dev/null @@ -1,300 +0,0 @@ -# noqa: D100, D101, D102, D103, D104, D107, D400, D415 -# ruff: noqa -# type: ignore -# pylint: skip-file -# Generated by protoc - do not edit -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" - -import grpc -import warnings - -from cpex.framework.external.grpc.proto import plugin_service_pb2 as plugin__service__pb2 - -GRPC_GENERATED_VERSION = "1.78.0" -GRPC_VERSION = grpc.__version__ -_version_not_supported = False - -try: - from grpc._utilities import first_version_is_lower - - _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) -except ImportError: - _version_not_supported = True - -if _version_not_supported: - raise RuntimeError( - f"The grpc package installed is at version {GRPC_VERSION}," - + " but the generated code in plugin_service_pb2_grpc.py depends on" - + f" grpcio>={GRPC_GENERATED_VERSION}." - + f" Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}" - + f" or downgrade your generated code using grpcio-tools<={GRPC_VERSION}." - ) - - -class PluginServiceStub(object): - """============================================================================ - Plugin Service - Main service for external plugin communication - ============================================================================ - - """ - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.GetPluginConfig = channel.unary_unary( - "/cpex.PluginService/GetPluginConfig", - request_serializer=plugin__service__pb2.GetPluginConfigRequest.SerializeToString, - response_deserializer=plugin__service__pb2.GetPluginConfigResponse.FromString, - _registered_method=True, - ) - self.GetPluginConfigs = channel.unary_unary( - "/cpex.PluginService/GetPluginConfigs", - request_serializer=plugin__service__pb2.GetPluginConfigsRequest.SerializeToString, - response_deserializer=plugin__service__pb2.GetPluginConfigsResponse.FromString, - _registered_method=True, - ) - self.InvokeHook = channel.unary_unary( - "/cpex.PluginService/InvokeHook", - request_serializer=plugin__service__pb2.InvokeHookRequest.SerializeToString, - response_deserializer=plugin__service__pb2.InvokeHookResponse.FromString, - _registered_method=True, - ) - - -class PluginServiceServicer(object): - """============================================================================ - Plugin Service - Main service for external plugin communication - ============================================================================ - - """ - - def GetPluginConfig(self, request, context): - """Get configuration for a single plugin by name""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details("Method not implemented!") - raise NotImplementedError("Method not implemented!") - - def GetPluginConfigs(self, request, context): - """Get configurations for all plugins on the server""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details("Method not implemented!") - raise NotImplementedError("Method not implemented!") - - def InvokeHook(self, request, context): - """Invoke a plugin hook""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details("Method not implemented!") - raise NotImplementedError("Method not implemented!") - - -def add_PluginServiceServicer_to_server(servicer, server): - rpc_method_handlers = { - "GetPluginConfig": grpc.unary_unary_rpc_method_handler( - servicer.GetPluginConfig, - request_deserializer=plugin__service__pb2.GetPluginConfigRequest.FromString, - response_serializer=plugin__service__pb2.GetPluginConfigResponse.SerializeToString, - ), - "GetPluginConfigs": grpc.unary_unary_rpc_method_handler( - servicer.GetPluginConfigs, - request_deserializer=plugin__service__pb2.GetPluginConfigsRequest.FromString, - response_serializer=plugin__service__pb2.GetPluginConfigsResponse.SerializeToString, - ), - "InvokeHook": grpc.unary_unary_rpc_method_handler( - servicer.InvokeHook, - request_deserializer=plugin__service__pb2.InvokeHookRequest.FromString, - response_serializer=plugin__service__pb2.InvokeHookResponse.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler("cpex.PluginService", rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers("cpex.PluginService", rpc_method_handlers) - - -# This class is part of an EXPERIMENTAL API. -class PluginService(object): - """============================================================================ - Plugin Service - Main service for external plugin communication - ============================================================================ - - """ - - @staticmethod - def GetPluginConfig( - request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.unary_unary( - request, - target, - "/cpex.PluginService/GetPluginConfig", - plugin__service__pb2.GetPluginConfigRequest.SerializeToString, - plugin__service__pb2.GetPluginConfigResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True, - ) - - @staticmethod - def GetPluginConfigs( - request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.unary_unary( - request, - target, - "/cpex.PluginService/GetPluginConfigs", - plugin__service__pb2.GetPluginConfigsRequest.SerializeToString, - plugin__service__pb2.GetPluginConfigsResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True, - ) - - @staticmethod - def InvokeHook( - request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.unary_unary( - request, - target, - "/cpex.PluginService/InvokeHook", - plugin__service__pb2.InvokeHookRequest.SerializeToString, - plugin__service__pb2.InvokeHookResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True, - ) - - -class HealthStub(object): - """============================================================================ - Health Service - Standard gRPC health checking - ============================================================================ - - """ - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.Check = channel.unary_unary( - "/cpex.Health/Check", - request_serializer=plugin__service__pb2.HealthCheckRequest.SerializeToString, - response_deserializer=plugin__service__pb2.HealthCheckResponse.FromString, - _registered_method=True, - ) - - -class HealthServicer(object): - """============================================================================ - Health Service - Standard gRPC health checking - ============================================================================ - - """ - - def Check(self, request, context): - """Check the health status of the plugin server""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details("Method not implemented!") - raise NotImplementedError("Method not implemented!") - - -def add_HealthServicer_to_server(servicer, server): - rpc_method_handlers = { - "Check": grpc.unary_unary_rpc_method_handler( - servicer.Check, - request_deserializer=plugin__service__pb2.HealthCheckRequest.FromString, - response_serializer=plugin__service__pb2.HealthCheckResponse.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler("cpex.Health", rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers("cpex.Health", rpc_method_handlers) - - -# This class is part of an EXPERIMENTAL API. -class Health(object): - """============================================================================ - Health Service - Standard gRPC health checking - ============================================================================ - - """ - - @staticmethod - def Check( - request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.unary_unary( - request, - target, - "/cpex.Health/Check", - plugin__service__pb2.HealthCheckRequest.SerializeToString, - plugin__service__pb2.HealthCheckResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True, - ) diff --git a/cpex/framework/external/grpc/server/__init__.py b/cpex/framework/external/grpc/server/__init__.py deleted file mode 100644 index f4b1d84c..00000000 --- a/cpex/framework/external/grpc/server/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/server/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -gRPC server package for external plugin transport. - -This package provides the gRPC servicer implementations that wrap the -ExternalPluginServer to expose plugin functionality via gRPC. -""" - -from cpex.framework.external.grpc.server.server import GrpcHealthServicer, GrpcPluginServicer - -__all__ = ["GrpcPluginServicer", "GrpcHealthServicer"] diff --git a/cpex/framework/external/grpc/server/runtime.py b/cpex/framework/external/grpc/server/runtime.py deleted file mode 100644 index 233738d0..00000000 --- a/cpex/framework/external/grpc/server/runtime.py +++ /dev/null @@ -1,321 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/server/runtime.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -gRPC server runtime for external plugins. - -This module provides the entry point for running a gRPC server that exposes -plugin functionality. It reuses the ExternalPluginServer for plugin loading -and wraps it with gRPC servicers. - -Usage: - python -m cpex.framework.external.grpc.server.runtime \\ - --config plugins/config.yaml \\ - --host 0.0.0.0 \\ - --port 50051 - -Environment Variables: - PLUGINS_CONFIG_PATH: Path to plugins configuration file (default: ./resources/plugins/config.yaml) - PLUGINS_GRPC_SERVER_HOST: Server host (default: 0.0.0.0) - PLUGINS_GRPC_SERVER_PORT: Server port (default: 50051) - PLUGINS_GRPC_SERVER_UDS: Unix domain socket path (alternative to host:port) - PLUGINS_GRPC_SERVER_SSL_ENABLED: Enable TLS (true/false). Required to enable TLS. Not supported with UDS. - PLUGINS_GRPC_SERVER_SSL_CERTFILE: Path to server certificate (required when SSL_ENABLED=true) - PLUGINS_GRPC_SERVER_SSL_KEYFILE: Path to server private key (required when SSL_ENABLED=true) - PLUGINS_GRPC_SERVER_SSL_CA_CERTS: Path to CA bundle for client verification (for mTLS) - PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH: Client auth requirement (none/optional/require, default: require) -""" - -# Standard -import argparse -import asyncio -import logging -import os -import signal -import sys -from typing import Optional - -# Third-Party -import grpc - -# First-Party -from cpex.framework.external.grpc.proto import plugin_service_pb2_grpc -from cpex.framework.external.grpc.server.server import GrpcHealthServicer, GrpcPluginServicer -from cpex.framework.external.grpc.tls_utils import create_server_credentials -from cpex.framework.external.mcp.server.server import ExternalPluginServer -from cpex.framework.models import GRPCServerConfig -from cpex.framework.settings import get_settings - -logger = logging.getLogger(__name__) - - -class GrpcPluginRuntime: - """Runtime manager for the gRPC plugin server. - - This class handles the lifecycle of the gRPC server, including: - - Plugin server initialization - - gRPC server setup and configuration - - TLS/mTLS configuration - - Graceful shutdown handling - - Examples: - >>> runtime = GrpcPluginRuntime(config_path="plugins/config.yaml") - >>> # In async context: - >>> # await runtime.start() - """ - - def __init__( - self, - config_path: Optional[str] = None, - host: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - """Initialize the gRPC plugin runtime. - - Args: - config_path: Path to the plugins configuration file. - host: Server host to bind to (overrides config/env). - port: Server port to bind to (overrides config/env). - """ - self._config_path = config_path - self._host_override = host - self._port_override = port - self._server: Optional[grpc.aio.Server] = None - self._plugin_server: Optional[ExternalPluginServer] = None - self._shutdown_event = asyncio.Event() - - async def start(self) -> None: - """Start the gRPC plugin server. - - This method: - 1. Creates and initializes the ExternalPluginServer - 2. Creates the gRPC server with servicers - 3. Configures TLS if enabled - 4. Starts serving requests - - Raises: - RuntimeError: If server fails to start. - """ - logger.info("Starting gRPC plugin server...") - - # Create and initialize the plugin server - self._plugin_server = ExternalPluginServer(config_path=self._config_path) - await self._plugin_server.initialize() - - # Get server configuration - server_config = self._get_server_config() - - # Determine bind address (UDS takes precedence, then overrides, then config) - if server_config.uds: - address = server_config.get_bind_address() - is_uds = True - else: - host = self._host_override or server_config.host - port = self._port_override or server_config.port - address = f"{host}:{port}" - is_uds = False - - # Create gRPC server - self._server = grpc.aio.server() - - # Add servicers - plugin_servicer = GrpcPluginServicer(self._plugin_server) - health_servicer = GrpcHealthServicer(self._plugin_server) - - plugin_service_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, self._server) - plugin_service_pb2_grpc.add_HealthServicer_to_server(health_servicer, self._server) - - # Configure address and TLS (TLS not supported for Unix domain sockets) - if not is_uds and server_config.tls is not None: - credentials = create_server_credentials(server_config.tls) - self._server.add_secure_port(address, credentials) - logger.info("gRPC server configured with TLS on %s", address) - else: - self._server.add_insecure_port(address) - if is_uds: - logger.info("gRPC server configured on Unix socket %s", server_config.uds) - else: - logger.warning("gRPC server configured WITHOUT TLS on %s - not recommended for production", address) - - # Start serving - await self._server.start() - - # Set restrictive permissions on Unix socket (owner read/write only) - if is_uds and server_config.uds and os.path.exists(server_config.uds): - os.chmod(server_config.uds, 0o600) - - logger.info("gRPC plugin server started on %s", address) - logger.info("Loaded %d plugins", len(await self._plugin_server.get_plugin_configs())) - - # Wait for shutdown signal - await self._shutdown_event.wait() - - async def stop(self) -> None: - """Stop the gRPC plugin server gracefully. - - This method: - 1. Stops accepting new connections - 2. Waits for existing connections to complete (with timeout) - 3. Shuts down the plugin server - """ - logger.info("Stopping gRPC plugin server...") - - if self._server: - # Stop accepting new connections - await self._server.stop(grace=5.0) - logger.info("gRPC server stopped") - - if self._plugin_server: - await self._plugin_server.shutdown() - logger.info("Plugin server shutdown complete") - - def request_shutdown(self) -> None: - """Request the server to shut down.""" - self._shutdown_event.set() - - def _get_server_config(self) -> GRPCServerConfig: - """Get the gRPC server configuration. - - Checks the plugin configuration file first, then falls back to - environment variables, then uses defaults. - - Returns: - GRPCServerConfig with server settings. - """ - # Check if config has gRPC server settings - if self._plugin_server: - grpc_config = self._plugin_server.get_grpc_server_config() - if grpc_config: - return grpc_config - - # Fall back to environment variables - env_config = GRPCServerConfig.from_env() - if env_config: - return env_config - - # Use defaults - return GRPCServerConfig() - - -async def run_server( - config_path: Optional[str] = None, - host: Optional[str] = None, - port: Optional[int] = None, -) -> None: - """Run the gRPC plugin server. - - Args: - config_path: Path to the plugins configuration file. - host: Server host to bind to. - port: Server port to bind to. - """ - runtime = GrpcPluginRuntime( - config_path=config_path, - host=host, - port=port, - ) - - # Set up signal handlers for graceful shutdown - loop = asyncio.get_running_loop() - - def signal_handler() -> None: - """Handle SIGINT/SIGTERM by requesting graceful shutdown.""" - logger.info("Received shutdown signal") - runtime.request_shutdown() - - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, signal_handler) - - try: - await runtime.start() - finally: - await runtime.stop() - - -def main() -> None: - """Main entry point for the gRPC plugin server.""" - parser = argparse.ArgumentParser( - description="gRPC server for ContextForge external plugins", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Start with default settings - python -m cpex.framework.external.grpc.server.runtime - - # Start with custom config and port - python -m cpex.framework.external.grpc.server.runtime \\ - --config plugins/config.yaml --port 50051 - - # Start with TLS enabled (configure via environment variables) - PLUGINS_GRPC_SERVER_SSL_ENABLED=true \\ - PLUGINS_GRPC_SERVER_SSL_CERTFILE=/path/to/server.pem \\ - PLUGINS_GRPC_SERVER_SSL_KEYFILE=/path/to/server-key.pem \\ - PLUGINS_GRPC_SERVER_SSL_CA_CERTS=/path/to/ca.pem \\ - python -m cpex.framework.external.grpc.server.runtime - """, - ) - - parser.add_argument( - "--config", - "-c", - type=str, - default=None, - help="Path to plugins configuration file", - ) - parser.add_argument( - "--host", - "-H", - type=str, - default=None, - help="Server host to bind to (default: 0.0.0.0)", - ) - parser.add_argument( - "--port", - "-p", - type=int, - default=None, - help="Server port to bind to (default: 50051)", - ) - parser.add_argument( - "--log-level", - "-l", - type=str, - default="INFO", - choices=["DEBUG", "INFO", "WARNING", "ERROR"], - help="Logging level (default: INFO)", - ) - - args = parser.parse_args() - - # Configure logging - respect PLUGINS_LOG_LEVEL environment variable - settings = get_settings() - log_level_str = settings.log_level or args.log_level - log_level = getattr(logging, log_level_str.upper(), logging.INFO) - logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stderr, - ) - - # Run the server - config_path = args.config or get_settings().config_path - try: - asyncio.run( - run_server( - config_path=config_path, - host=args.host, - port=args.port, - ) - ) - except KeyboardInterrupt: - logger.info("Server shutdown complete") - sys.exit(0) - except Exception as e: - logger.error("Server failed: %s", e) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/cpex/framework/external/grpc/server/server.py b/cpex/framework/external/grpc/server/server.py deleted file mode 100644 index 562ef97a..00000000 --- a/cpex/framework/external/grpc/server/server.py +++ /dev/null @@ -1,257 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/server/server.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -gRPC servicer implementations for external plugin server. - -This module provides gRPC servicer classes that adapt gRPC calls to the -ExternalPluginServer, which handles the actual plugin loading and execution. -""" - -# pylint: disable=no-member,no-name-in-module - -# Standard -import logging -from typing import Any - -import grpc - -# Third-Party -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Struct - -# First-Party -from cpex.framework.external.grpc.proto import plugin_service_pb2, plugin_service_pb2_grpc -from cpex.framework.external.mcp.server.server import ExternalPluginServer -from cpex.framework.external.proto_convert import ( - proto_context_to_pydantic, - pydantic_context_to_proto, -) -from cpex.framework.models import PluginContext - -logger = logging.getLogger(__name__) - - -class GrpcPluginServicer(plugin_service_pb2_grpc.PluginServiceServicer): - """gRPC servicer that adapts gRPC calls to ExternalPluginServer. - - This servicer wraps an ExternalPluginServer instance and translates - between gRPC protocol buffer messages and the Pydantic models used - by the plugin framework. - - Examples: - >>> from cpex.framework.external.mcp.server.server import ExternalPluginServer - >>> plugin_server = ExternalPluginServer(config_path="plugins/config.yaml") - >>> servicer = GrpcPluginServicer(plugin_server) - """ - - def __init__(self, plugin_server: ExternalPluginServer) -> None: - """Initialize the gRPC servicer with a plugin server. - - Args: - plugin_server: The ExternalPluginServer instance that handles - plugin loading and execution. - """ - self._plugin_server = plugin_server - - async def GetPluginConfig( # pylint: disable=invalid-overridden-method - self, - request: plugin_service_pb2.GetPluginConfigRequest, - context: grpc.aio.ServicerContext, - ) -> plugin_service_pb2.GetPluginConfigResponse: - """Get configuration for a single plugin by name. - - Args: - request: gRPC request containing the plugin name. - context: gRPC servicer context. - - Returns: - Response containing the plugin configuration or empty if not found. - """ - logger.debug("GetPluginConfig called for plugin: %s", request.name) - - try: - config = await self._plugin_server.get_plugin_config(request.name) - - response = plugin_service_pb2.GetPluginConfigResponse() - if config: - response.found = True - json_format.ParseDict(config, response.config) - else: - response.found = False - - return response - - except Exception as e: - logger.exception("Error in GetPluginConfig: %s", e) - context.set_code(grpc.StatusCode.INTERNAL) - context.set_details(str(e)) - return plugin_service_pb2.GetPluginConfigResponse(found=False) - - async def GetPluginConfigs( # pylint: disable=invalid-overridden-method - self, - request: plugin_service_pb2.GetPluginConfigsRequest, - context: grpc.aio.ServicerContext, - ) -> plugin_service_pb2.GetPluginConfigsResponse: - """Get configurations for all plugins on the server. - - Args: - request: gRPC request (empty). - context: gRPC servicer context. - - Returns: - Response containing list of all plugin configurations. - """ - logger.debug("GetPluginConfigs called") - - try: - configs = await self._plugin_server.get_plugin_configs() - - response = plugin_service_pb2.GetPluginConfigsResponse() - for config in configs: - config_struct = Struct() - json_format.ParseDict(config, config_struct) - response.configs.append(config_struct) - - return response - - except Exception as e: - logger.exception("Error in GetPluginConfigs: %s", e) - context.set_code(grpc.StatusCode.INTERNAL) - context.set_details(str(e)) - return plugin_service_pb2.GetPluginConfigsResponse() - - async def InvokeHook( # pylint: disable=invalid-overridden-method - self, - request: plugin_service_pb2.InvokeHookRequest, - context: grpc.aio.ServicerContext, - ) -> plugin_service_pb2.InvokeHookResponse: - """Invoke a plugin hook. - - Args: - request: gRPC request containing hook_type, plugin_name, payload, and context. - context: gRPC servicer context. - - Returns: - Response containing the plugin result or error. - """ - logger.debug( - "InvokeHook called: hook_type=%s, plugin_name=%s", - request.hook_type, - request.plugin_name, - ) - - try: - # Convert payload Struct to Python dict (still polymorphic) - payload_dict = json_format.MessageToDict(request.payload) - - # Convert explicit PluginContext proto directly to Pydantic - context_pydantic = proto_context_to_pydantic(request.context) - - # Invoke the hook using the plugin server (passing Pydantic context directly) - result = await self._plugin_server.invoke_hook( - hook_type=request.hook_type, - plugin_name=request.plugin_name, - payload=payload_dict, - context=context_pydantic, - ) - - # Build the response - response = plugin_service_pb2.InvokeHookResponse(plugin_name=request.plugin_name) - - # Check for error in result - if "error" in result: - error_obj = result["error"] - # Handle both Pydantic models and dicts - if hasattr(error_obj, "model_dump"): - error_dict = error_obj.model_dump() - else: - error_dict = error_obj - response.error.CopyFrom(self._dict_to_plugin_error(error_dict)) - else: - # Convert result to Struct (still polymorphic) - if "result" in result: - json_format.ParseDict(result["result"], response.result) - # Convert context to explicit proto message - if "context" in result: - ctx = result["context"] - # Handle both Pydantic (optimized path) and dict (MCP compat) - if isinstance(ctx, PluginContext): - response.context.CopyFrom(pydantic_context_to_proto(ctx)) - else: - updated_context = PluginContext.model_validate(ctx) - response.context.CopyFrom(pydantic_context_to_proto(updated_context)) - - return response - - except Exception as e: - logger.exception("Error in InvokeHook: %s", e) - response = plugin_service_pb2.InvokeHookResponse(plugin_name=request.plugin_name) - response.error.message = str(e) - response.error.plugin_name = request.plugin_name - response.error.code = "INTERNAL_ERROR" - response.error.mcp_error_code = -32603 - return response - - def _dict_to_plugin_error(self, error_dict: dict[str, Any]) -> plugin_service_pb2.PluginError: - """Convert an error dictionary to a PluginError protobuf message. - - Args: - error_dict: Dictionary containing error information. - - Returns: - PluginError protobuf message. - """ - error = plugin_service_pb2.PluginError() - error.message = error_dict.get("message", "Unknown error") - error.plugin_name = error_dict.get("plugin_name", "unknown") - error.code = error_dict.get("code", "") - error.mcp_error_code = error_dict.get("mcp_error_code", -32603) - - if "details" in error_dict and error_dict["details"]: - json_format.ParseDict(error_dict["details"], error.details) - - return error - - -class GrpcHealthServicer(plugin_service_pb2_grpc.HealthServicer): - """gRPC health check servicer following the standard gRPC health protocol. - - This servicer provides health check endpoints that can be used by - load balancers and orchestration systems to verify the server is - operational. - - Examples: - >>> servicer = GrpcHealthServicer() - >>> # Register with gRPC server - """ - - def __init__(self, plugin_server: ExternalPluginServer | None = None) -> None: - """Initialize the health servicer. - - Args: - plugin_server: Optional ExternalPluginServer for checking plugin health. - """ - self._plugin_server = plugin_server - - async def Check( # pylint: disable=invalid-overridden-method - self, - request: plugin_service_pb2.HealthCheckRequest, - context: grpc.aio.ServicerContext, - ) -> plugin_service_pb2.HealthCheckResponse: - """Check the health status of the server. - - Args: - request: Health check request with optional service name. - context: gRPC servicer context. - - Returns: - Health check response with serving status. - """ - logger.debug("Health check called for service: %s", request.service or "(overall)") - - # For now, always return SERVING if the server is running - # In the future, could check plugin_server health - return plugin_service_pb2.HealthCheckResponse(status=plugin_service_pb2.HealthCheckResponse.SERVING) diff --git a/cpex/framework/external/grpc/tls_utils.py b/cpex/framework/external/grpc/tls_utils.py deleted file mode 100644 index 7fbff356..00000000 --- a/cpex/framework/external/grpc/tls_utils.py +++ /dev/null @@ -1,202 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/grpc/tls_utils.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -gRPC TLS credential utilities for external plugin transport. - -This module provides helper functions for creating gRPC channel and server -credentials with TLS/mTLS support. -""" - -# Standard -import logging -from typing import Optional - -# Third-Party -import grpc - -# First-Party -from cpex.framework.models import GRPCClientTLSConfig, GRPCServerTLSConfig - -logger = logging.getLogger(__name__) - - -def _read_file(path: str) -> bytes: - """Read file contents as bytes. - - Args: - path: Path to the file to read. - - Returns: - File contents as bytes. - - Raises: - FileNotFoundError: If file does not exist. - IOError: If file cannot be read. - """ - with open(path, "rb") as f: - return f.read() - - -def create_client_credentials(tls_config: GRPCClientTLSConfig, plugin_name: str = "unknown") -> grpc.ChannelCredentials: - """Create gRPC channel credentials for client connections. - - This function creates SSL channel credentials for connecting to a gRPC server. - It supports: - - Server certificate verification (with custom CA bundle) - - Client certificate authentication (mTLS) - - Disabling verification (not recommended for production) - - Args: - tls_config: TLS configuration containing certificate paths and options. - plugin_name: Name of the plugin for logging purposes. - - Returns: - gRPC ChannelCredentials configured for TLS/mTLS. - - Raises: - FileNotFoundError: If certificate files are not found. - ValueError: If TLS configuration is invalid. - - Examples: - >>> from cpex.framework.models import GRPCClientTLSConfig - >>> config = GRPCClientTLSConfig( # doctest: +SKIP - ... ca_bundle="/path/to/ca.pem", - ... certfile="/path/to/client.pem", - ... keyfile="/path/to/client-key.pem", - ... verify=True - ... ) - >>> creds = create_client_credentials(config, "my_plugin") # doctest: +SKIP - """ - root_certificates: Optional[bytes] = None - private_key: Optional[bytes] = None - certificate_chain: Optional[bytes] = None - - # Load CA bundle for server verification - if tls_config.ca_bundle: - logger.debug("Loading CA bundle for plugin %s: %s", plugin_name, tls_config.ca_bundle) - root_certificates = _read_file(tls_config.ca_bundle) - - # Load client certificate for mTLS - if tls_config.certfile and tls_config.keyfile: - logger.debug("Loading client certificate for plugin %s: %s", plugin_name, tls_config.certfile) - certificate_chain = _read_file(tls_config.certfile) - private_key = _read_file(tls_config.keyfile) - - # Handle verification setting - if not tls_config.verify: - logger.warning("TLS verification disabled for plugin %s - not recommended for production", plugin_name) - # When verification is disabled, we still create credentials but without root certificates - # This allows the connection but skips certificate validation - # Note: grpc-python doesn't have a direct "skip verify" option, so we use empty root_certificates - # which effectively disables server certificate validation - return grpc.ssl_channel_credentials( - root_certificates=None, - private_key=private_key, - certificate_chain=certificate_chain, - ) - - return grpc.ssl_channel_credentials( - root_certificates=root_certificates, - private_key=private_key, - certificate_chain=certificate_chain, - ) - - -def create_server_credentials(tls_config: GRPCServerTLSConfig) -> grpc.ServerCredentials: - """Create gRPC server credentials for accepting client connections. - - This function creates SSL server credentials for a gRPC server. - It supports: - - Server certificate presentation - - Client certificate authentication (mTLS with configurable requirements) - - Args: - tls_config: TLS configuration containing certificate paths and client auth settings. - - Returns: - gRPC ServerCredentials configured for TLS/mTLS. - - Raises: - FileNotFoundError: If certificate files are not found. - ValueError: If required certificates are not provided. - - Examples: - >>> from cpex.framework.models import GRPCServerTLSConfig - >>> config = GRPCServerTLSConfig( # doctest: +SKIP - ... certfile="/path/to/server.pem", - ... keyfile="/path/to/server-key.pem", - ... ca_bundle="/path/to/ca.pem", - ... client_auth="require" - ... ) - >>> creds = create_server_credentials(config) # doctest: +SKIP - """ - if not tls_config.certfile or not tls_config.keyfile: - raise ValueError("Server certificate (certfile) and private key (keyfile) are required for gRPC TLS") - - logger.debug("Loading server certificate: %s", tls_config.certfile) - server_certificate = _read_file(tls_config.certfile) - private_key = _read_file(tls_config.keyfile) - - # Load CA bundle for client certificate verification - root_certificates: Optional[bytes] = None - if tls_config.ca_bundle: - logger.debug("Loading CA bundle for client verification: %s", tls_config.ca_bundle) - root_certificates = _read_file(tls_config.ca_bundle) - - # Map client_auth setting to gRPC requirement - client_auth_map = { - "none": False, - "optional": False, # gRPC doesn't have "optional" - handled in application layer - "require": True, - } - require_client_auth = client_auth_map.get(tls_config.client_auth.lower(), True) - - logger.info( - "Creating gRPC server credentials with client_auth=%s (require_client_auth=%s)", - tls_config.client_auth, - require_client_auth, - ) - - return grpc.ssl_server_credentials( - private_key_certificate_chain_pairs=[(private_key, server_certificate)], - root_certificates=root_certificates, - require_client_auth=require_client_auth, - ) - - -def create_insecure_channel(target: str) -> grpc.aio.Channel: - """Create an insecure gRPC channel (no TLS). - - Args: - target: The target address in host:port format. - - Returns: - An insecure async gRPC channel. - - Note: - This should only be used for development/testing. - Production deployments should always use TLS. - """ - logger.warning("Creating insecure gRPC channel to %s - not recommended for production", target) - return grpc.aio.insecure_channel(target) - - -def create_secure_channel( - target: str, tls_config: GRPCClientTLSConfig, plugin_name: str = "unknown" -) -> grpc.aio.Channel: - """Create a secure gRPC channel with TLS. - - Args: - target: The target address in host:port format. - tls_config: TLS configuration for the channel. - plugin_name: Name of the plugin for logging purposes. - - Returns: - A secure async gRPC channel with TLS credentials. - """ - credentials = create_client_credentials(tls_config, plugin_name) - logger.info("Creating secure gRPC channel to %s for plugin %s", target, plugin_name) - return grpc.aio.secure_channel(target, credentials) diff --git a/cpex/framework/external/mcp/__init__.py b/cpex/framework/external/mcp/__init__.py deleted file mode 100644 index f1287a19..00000000 --- a/cpex/framework/external/mcp/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/mcp/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -External plugins package. -Exposes external plugin components: -- server -- external plugin client -""" diff --git a/cpex/framework/external/mcp/client.py b/cpex/framework/external/mcp/client.py deleted file mode 100644 index 9b410792..00000000 --- a/cpex/framework/external/mcp/client.py +++ /dev/null @@ -1,695 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/mcp/client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Fred Araujo - -External plugin client which connects to a remote server through MCP. -Module that contains plugin MCP client code to serve external plugins. -""" - -# Standard -import asyncio -import logging -import os -import sys -from contextlib import AsyncExitStack -from functools import partial -from pathlib import Path -from typing import Any, Awaitable, Callable, Optional - -# Third-Party -import httpx -import orjson -from mcp import ClientSession, McpError, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.client.streamable_http import streamablehttp_client -from mcp.types import TextContent - -# First-Party -from cpex.framework.base import HookRef, Plugin, PluginRef -from cpex.framework.constants import ( - CONTEXT, - ERROR, - GET_PLUGIN_CONFIG, - HOOK_TYPE, - IGNORE_CONFIG_EXTERNAL, - INVOKE_HOOK, - NAME, - PAYLOAD, - PLUGIN_NAME, - PYTHON_SUFFIX, - RESULT, -) -from cpex.framework.errors import PluginError, convert_exception_to_error -from cpex.framework.external.mcp.tls_utils import create_ssl_context -from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.models import ( - MCPClientTLSConfig, - PluginConfig, - PluginContext, - PluginErrorModel, - PluginPayload, - PluginResult, - TransportType, -) -from cpex.framework.settings import get_http_client_settings - -logger = logging.getLogger(__name__) - - -class ExternalPlugin(Plugin): - """External plugin object for pre/post processing of inputs and outputs at various locations throughout the gateway. - - The External Plugin connects to a remote MCP server that contains plugins. - """ - - def __init__(self, config: PluginConfig) -> None: - """Initialize a plugin with a configuration and context. - - Args: - config: The plugin configuration - """ - super().__init__(config) - self._session: Optional[ClientSession] = None - self._exit_stack = AsyncExitStack() - self._http: Optional[Any] - self._stdio: Optional[Any] - self._write: Optional[Any] - self._current_task = asyncio.current_task() - self._stdio_exit_stack: Optional[AsyncExitStack] = None - self._stdio_task: Optional[asyncio.Task[None]] = None - self._stdio_ready: Optional[asyncio.Event] = None - self._stdio_stop: Optional[asyncio.Event] = None - self._stdio_error: Optional[BaseException] = None - self._get_session_id: Optional[Callable[[], str | None]] = None - self._session_id: Optional[str] = None - self._http_client_factory: Optional[Callable[..., httpx.AsyncClient]] = None - self._reconnect_attempts: int = 3 - self._reconnect_delay: float = 0.1 - self._reconnect_lock: asyncio.Lock = asyncio.Lock() - - async def initialize(self) -> None: - """Initialize the plugin's connection to the MCP server. - - Raises: - PluginError: if unable to retrieve plugin configuration of external plugin. - """ - - if not self._config.mcp: - raise PluginError( - error=PluginErrorModel( - message="The mcp section must be defined for external plugin", plugin_name=self.name - ) - ) - - self._reconnect_attempts = self._config.mcp.reconnect_attempts - self._reconnect_delay = self._config.mcp.reconnect_delay - - if self._config.mcp.proto == TransportType.STDIO: - if not (self._config.mcp.script or self._config.mcp.cmd): - raise PluginError( - error=PluginErrorModel(message="STDIO transport requires script or cmd", plugin_name=self.name) - ) - await self.__connect_to_stdio_server( - self._config.mcp.script, self._config.mcp.cmd, self._config.mcp.env, self._config.mcp.cwd - ) - elif self._config.mcp.proto == TransportType.STREAMABLEHTTP: - if not self._config.mcp.url: - raise PluginError( - error=PluginErrorModel(message="STREAMABLEHTTP transport requires url", plugin_name=self.name) - ) - await self.__connect_to_http_server(self._config.mcp.url) - - try: - config = await self.__get_plugin_config() - - if not config: - raise PluginError( - error=PluginErrorModel( - message="Unable to retrieve configuration for external plugin", plugin_name=self.name - ) - ) - - current_config = self._config.model_dump(exclude_unset=True) - remote_config = config.model_dump(exclude_unset=True) - remote_config.update(current_config) - - context = {IGNORE_CONFIG_EXTERNAL: True} - - self._config = PluginConfig.model_validate(remote_config, context=context) - except PluginError as pe: - try: - await self.shutdown() - except Exception as shutdown_error: - logger.error("Error during external plugin shutdown after init failure: %s", shutdown_error) - logger.exception(pe) - raise - except Exception as e: - try: - await self.shutdown() - except Exception as shutdown_error: - logger.error("Error during external plugin shutdown after init failure: %s", shutdown_error) - logger.exception(e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - def __resolve_stdio_command( - self, script_path: str | None, cmd: list[str] | None, cwd: str | None - ) -> tuple[str, list[str]]: - """Resolve the stdio command + args from config. - - Args: - script_path: Path to a server script or executable. - cmd: Command list to execute (command + args). - cwd: Working directory for resolving relative script paths. - - Returns: - Tuple of (command, args). - - Raises: - PluginError: if the script is invalid or cmd is malformed. - """ - if cmd: - if not isinstance(cmd, list) or not cmd or not all(isinstance(part, str) and part.strip() for part in cmd): - raise PluginError( - error=PluginErrorModel( - message="STDIO cmd must be a non-empty list of strings", plugin_name=self.name - ) - ) - return cmd[0], cmd[1:] - - if not script_path: - raise PluginError( - error=PluginErrorModel(message="STDIO transport requires script or cmd", plugin_name=self.name) - ) - - server_path = Path(script_path).expanduser() - if not server_path.is_absolute() and cwd: - server_path = Path(cwd).expanduser() / server_path - resolved_script_path = str(server_path) - if not server_path.is_file(): - raise PluginError( - error=PluginErrorModel( - message=f"Server script {resolved_script_path} does not exist.", plugin_name=self.name - ) - ) - - if server_path.suffix == PYTHON_SUFFIX: - return sys.executable, [resolved_script_path] - if server_path.suffix == ".sh": - return "sh", [resolved_script_path] - if not os.access(server_path, os.X_OK): - raise PluginError( - error=PluginErrorModel( - message=f"Server script {resolved_script_path} must be executable.", plugin_name=self.name - ) - ) - return resolved_script_path, [] - - def __build_stdio_env(self, extra_env: dict[str, str] | None) -> dict[str, str]: - """Build environment for the stdio server process. - - Args: - extra_env: Environment overrides to merge into the current process env. - - Returns: - Combined environment dictionary for the plugin process. - """ - current_env = os.environ.copy() - if extra_env: - current_env.update(extra_env) - return current_env - - async def __run_stdio_session( - self, server_script_path: str | None, cmd: list[str] | None, env: dict[str, str] | None, cwd: str | None - ) -> None: - """Run a stdio session in a dedicated task for consistent setup/teardown. - - Args: - server_script_path: Path to the server script or executable. - cmd: Command list to start the server (command + args). - env: Environment overrides for the server process. - cwd: Working directory for the server process. - """ - try: - command, args = self.__resolve_stdio_command(server_script_path, cmd, cwd) - server_env = self.__build_stdio_env(env) - server_params = StdioServerParameters(command=command, args=args, env=server_env, cwd=cwd) - - self._stdio_exit_stack = AsyncExitStack() - stdio_transport = await self._stdio_exit_stack.enter_async_context(stdio_client(server_params)) - self._stdio, self._write = stdio_transport - self._session = await self._stdio_exit_stack.enter_async_context(ClientSession(self._stdio, self._write)) - - await self._session.initialize() - - response = await self._session.list_tools() - tools = response.tools - logger.info( - "\nConnected to plugin MCP server (stdio) with tools: %s", " ".join([tool.name for tool in tools]) - ) - except Exception as e: - self._stdio_error = e - logger.exception(e) - finally: - if self._stdio_ready and not self._stdio_ready.is_set(): - self._stdio_ready.set() - - if self._stdio_error: - if self._stdio_exit_stack: - await self._stdio_exit_stack.aclose() - return - - if self._stdio_stop: - await self._stdio_stop.wait() - - if self._stdio_exit_stack: - await self._stdio_exit_stack.aclose() - - async def __connect_to_stdio_server( - self, server_script_path: str | None, cmd: list[str] | None, env: dict[str, str] | None, cwd: str | None - ) -> None: - """Connect to an MCP plugin server via stdio. - - Args: - server_script_path: Path to the server script or executable. - cmd: Command list to start the server (command + args). - env: Environment overrides for the server process. - cwd: Working directory for the server process. - - Raises: - PluginError: if stdio script/cmd is invalid or if there is a connection error. - """ - try: - if not self._stdio_ready: - self._stdio_ready = asyncio.Event() - if not self._stdio_stop: - self._stdio_stop = asyncio.Event() - self._stdio_error = None - - self._stdio_task = asyncio.create_task( - self.__run_stdio_session(server_script_path, cmd, env, cwd), - name=f"external-plugin-stdio-{self.name}", - ) - - await self._stdio_ready.wait() - if self._stdio_error: - raise PluginError(error=convert_exception_to_error(self._stdio_error, plugin_name=self.name)) - except PluginError: - raise - except Exception as e: - logger.exception(e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - async def __connect_to_http_server(self, uri: str) -> None: - """Connect to an MCP plugin server via streamable http with retry logic. - - Args: - uri: the URI of the mcp plugin server. - - Raises: - PluginError: if there is an external connection error after all retries. - """ - plugin_tls = self._config.mcp.tls if self._config and self._config.mcp else None - uds_path = self._config.mcp.uds if self._config and self._config.mcp else None - if uds_path and plugin_tls: - logger.warning("TLS configuration is ignored for Unix domain socket connections.") - tls_config = None if uds_path else (plugin_tls or MCPClientTLSConfig.from_env()) - - def _tls_httpx_client_factory( - headers: Optional[dict[str, str]] = None, - timeout: Optional[httpx.Timeout] = None, - auth: Optional[httpx.Auth] = None, - ) -> httpx.AsyncClient: - """Build an httpx client with TLS configuration for external MCP servers. - - Args: - headers: Optional HTTP headers to include in requests. - timeout: Optional timeout configuration for HTTP requests. - auth: Optional authentication handler for HTTP requests. - - Returns: - Configured httpx AsyncClient with TLS settings applied. - - Raises: - PluginError: If TLS configuration fails. - """ - - kwargs: dict[str, Any] = {"follow_redirects": True} - if uds_path: - kwargs["transport"] = httpx.AsyncHTTPTransport(uds=uds_path) - if headers: - kwargs["headers"] = headers - http_settings = get_http_client_settings() - kwargs["timeout"] = ( - timeout - if timeout - else httpx.Timeout( - connect=http_settings.httpx_connect_timeout, - read=http_settings.httpx_read_timeout, - write=http_settings.httpx_write_timeout, - pool=http_settings.httpx_pool_timeout, - ) - ) - if auth is not None: - kwargs["auth"] = auth - - # Add connection pool limits - kwargs["limits"] = httpx.Limits( - max_connections=http_settings.httpx_max_connections, - max_keepalive_connections=http_settings.httpx_max_keepalive_connections, - keepalive_expiry=http_settings.httpx_keepalive_expiry, - ) - - if not tls_config: - # Use skip_ssl_verify setting when no custom TLS config - kwargs["verify"] = not http_settings.skip_ssl_verify - return httpx.AsyncClient(**kwargs) - - # Create SSL context using the utility function - # This implements certificate validation per test_client_certificate_validation.py - ssl_context = create_ssl_context(tls_config, self.name) - kwargs["verify"] = ssl_context - - return httpx.AsyncClient(**kwargs) - - self._http_client_factory = _tls_httpx_client_factory - max_retries = 3 - base_delay = 1.0 - - for attempt in range(max_retries): - try: - client_factory = _tls_httpx_client_factory - streamable_client = streamablehttp_client( - uri, httpx_client_factory=client_factory, terminate_on_close=False - ) - http_transport = await self._exit_stack.enter_async_context(streamable_client) - self._http, self._write, get_session_id = http_transport - self._get_session_id = get_session_id - self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write)) - - await self._session.initialize() - self._session_id = self._get_session_id() if self._get_session_id else None - response = await self._session.list_tools() - tools = response.tools - logger.info( - "Successfully connected to plugin MCP server with tools: %s", - " ".join([tool.name for tool in tools]), - ) - return - except Exception as e: - logger.warning("Connection attempt %d/%d failed: %s", attempt + 1, max_retries, e) - if attempt == max_retries - 1: - # Final attempt failed - target = f"{uri} (uds={uds_path})" if uds_path else uri - error_msg = f"External plugin '{self.name}' connection failed after {max_retries} attempts: {target} is not reachable. Please ensure the MCP server is running." - logger.error(error_msg) - raise PluginError(error=PluginErrorModel(message=error_msg, plugin_name=self.name)) - await self.shutdown() - self._exit_stack = AsyncExitStack() - # Wait before retry - delay = base_delay * (2**attempt) - logger.info("Retrying in %ss...", delay) - await asyncio.sleep(delay) - - async def _cleanup_session(self) -> None: - """Reset session state without a full shutdown (no task await/stop). - - Used by reconnection logic to tear down stale state before re-establishing. - """ - self._stdio_error = None - - if self._exit_stack: - await self._exit_stack.aclose() - self._exit_stack = AsyncExitStack() - if self._stdio_task: - if self._stdio_stop: - self._stdio_stop.set() - try: - await asyncio.wait_for(self._stdio_task, timeout=5.0) - except asyncio.TimeoutError: - logger.warning("Stdio task for plugin %s did not exit within 5s, cancelling", self.name) - self._stdio_task.cancel() - try: - await self._stdio_task - except (asyncio.CancelledError, Exception): - pass - except Exception as e: - logger.debug("Error stopping stdio task during cleanup: %s", e) - self._stdio_task = None - self._stdio_ready = None - self._stdio_stop = None - if self._stdio_exit_stack: - await self._stdio_exit_stack.aclose() - self._stdio_exit_stack = None - self._session = None - self._http = None - self._write = None - self._stdio = None - self._get_session_id = None - self._session_id = None - - async def _reconnect_session(self) -> None: - """Tear down old session and reconnect to MCP server with linear backoff. - - Raises: - PluginError: If reconnection fails after all attempts. - """ - logger.info("Attempting to reconnect to MCP server: %s", self.name) - - await self._cleanup_session() - - last_error: Optional[Exception] = None - for attempt in range(1, self._reconnect_attempts + 1): - try: - logger.debug("Reconnection attempt %d/%d to %s", attempt, self._reconnect_attempts, self.name) - - if self._config.mcp.proto == TransportType.STREAMABLEHTTP: - await self.__connect_to_http_server(self._config.mcp.url) - elif self._config.mcp.proto == TransportType.STDIO: - await self.__connect_to_stdio_server( - self._config.mcp.script, self._config.mcp.cmd, self._config.mcp.env, self._config.mcp.cwd - ) - - logger.info("Reconnected to MCP server on attempt %d: %s", attempt, self.name) - return - except Exception as e: - last_error = e - if attempt < self._reconnect_attempts: - delay = self._reconnect_delay * attempt - logger.warning("Reconnection attempt %d failed: %s. Retrying in %ss...", attempt, e, delay) - await asyncio.sleep(delay) - - raise PluginError( - error=PluginErrorModel( - message=f"Failed to reconnect after {self._reconnect_attempts} attempts: {last_error}", - plugin_name=self.name, - ) - ) - - async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: - """Invoke an external plugin hook using the MCP protocol. - - Args: - hook_type: The type of hook invoked (i.e., prompt_pre_fetch) - payload: The payload to be passed to the hook. - context: The plugin context passed to the run. - - Raises: - PluginError: error passed from external plugin server. - - Returns: - The resulting payload from the plugin. - """ - # Get the result type from the global registry - registry = get_hook_registry() - result_type = registry.get_result_type(hook_type) - if not result_type: - raise PluginError( - error=PluginErrorModel( - message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name - ) - ) - - if not self._session: - raise PluginError(error=PluginErrorModel(message="Plugin session not initialized", plugin_name=self.name)) - - async def _execute_call() -> PluginResult: - """Execute the MCP tool call and parse the result.""" - call_result = await self._session.call_tool( - INVOKE_HOOK, {HOOK_TYPE: hook_type, PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context} - ) - for content in call_result.content: - if not isinstance(content, TextContent): - continue - try: - res = orjson.loads(content.text) - except orjson.JSONDecodeError: - raise PluginError( - error=PluginErrorModel( - message=f"Error trying to decode json: {content.text}", - code="JSON_DECODE_ERROR", - plugin_name=self.name, - ) - ) - if CONTEXT in res: - cxt = PluginContext.model_validate(res[CONTEXT]) - context.state = cxt.state - context.metadata = cxt.metadata - context.global_context.state = cxt.global_context.state - if RESULT in res: - return result_type.model_validate(res[RESULT]) - if ERROR in res: - error_model = PluginErrorModel.model_validate(res[ERROR]) - raise PluginError(error_model) - raise PluginError( - error=PluginErrorModel( - message=f"Received invalid response. Result = {call_result}", plugin_name=self.name - ) - ) - - try: - return await _execute_call() - except PluginError as pe: - error_msg = str(pe.error.message).lower() if pe.error and pe.error.message else "" - if "session" in error_msg and "terminated" in error_msg: - logger.warning("Session terminated for plugin %s, attempting reconnection...", self.name) - try: - async with self._reconnect_lock: - await self._reconnect_session() - return await _execute_call() - except PluginError: - raise - except Exception as reconn_err: - logger.exception("Reconnection failed for plugin %s: %s", self.name, reconn_err) - raise PluginError( - error=PluginErrorModel( - message=f"Reconnection failed for plugin {self.name}: {reconn_err}", - plugin_name=self.name, - ) - ) from reconn_err - logger.exception(pe) - raise - except McpError as e: - logger.warning("McpError for plugin %s: %s", self.name, e) - try: - async with self._reconnect_lock: - await self._reconnect_session() - return await _execute_call() - except PluginError: - raise - except Exception as reconn_err: - logger.exception("Reconnection failed for plugin %s: %s", self.name, reconn_err) - raise PluginError( - error=PluginErrorModel( - message=f"Reconnection failed for plugin {self.name}: {reconn_err}", - plugin_name=self.name, - ) - ) from reconn_err - except Exception as e: - logger.exception(e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - async def __get_plugin_config(self) -> PluginConfig | None: - """Retrieve plugin configuration for the current plugin on the remote MCP server. - - Raises: - PluginError: if there is a connection issue or validation issue. - - Returns: - A plugin configuration for the current plugin from a remote MCP server. - """ - if not self._session: - raise PluginError(error=PluginErrorModel(message="Plugin session not initialized", plugin_name=self.name)) - try: - configs = await self._session.call_tool(GET_PLUGIN_CONFIG, {NAME: self.name}) - for content in configs.content: - if not isinstance(content, TextContent): - continue - conf = orjson.loads(content.text) - if not conf: - return None - return PluginConfig.model_validate(conf) - except Exception as e: - logger.exception(e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - return None - - async def shutdown(self) -> None: - """Plugin cleanup code.""" - if self._stdio_task: - if self._stdio_stop: - self._stdio_stop.set() - try: - await self._stdio_task - except Exception as e: - logger.error("Error shutting down stdio session for plugin %s: %s", self.name, e) - self._stdio_task = None - self._stdio_ready = None - self._stdio_stop = None - self._stdio_exit_stack = None - self._stdio_error = None - self._stdio = None - self._write = None - if self._config and self._config.mcp and self._config.mcp.proto == TransportType.STDIO: - self._session = None - - if self._exit_stack: - await self._exit_stack.aclose() - if self._config and self._config.mcp and self._config.mcp.proto == TransportType.STREAMABLEHTTP: - await self.__terminate_http_session() - self._get_session_id = None - self._session_id = None - self._http_client_factory = None - - async def __terminate_http_session(self) -> None: - """Terminate streamable HTTP session explicitly to avoid lingering server state.""" - if not self._session_id or not self._config or not self._config.mcp or not self._config.mcp.url: - return - # Third-Party - from mcp.server.streamable_http import MCP_SESSION_ID_HEADER # pylint: disable=import-outside-toplevel - - client_factory = self._http_client_factory - try: - if client_factory: - client = client_factory() - else: - client = httpx.AsyncClient(follow_redirects=True) - async with client: - headers = {MCP_SESSION_ID_HEADER: self._session_id} - await client.delete(self._config.mcp.url, headers=headers) - except Exception as exc: - logger.debug("Failed to terminate streamable HTTP session: %s", exc) - - -class ExternalHookRef(HookRef): - """A Hook reference point for external plugins.""" - - def __init__(self, hook: str, plugin_ref: PluginRef): # pylint: disable=super-init-not-called - """Initialize a hook reference point for an external plugin. - - Note: We intentionally don't call super().__init__() because external plugins - use invoke_hook() rather than direct method attributes. - - Args: - hook: name of the hook point. - plugin_ref: The reference to the plugin to hook. - - Raises: - PluginError: If the plugin is not an external plugin. - """ - self._plugin_ref = plugin_ref - self._hook = hook - self._accepts_extensions = False # External plugins use invoke_hook(), not direct method calls - if hasattr(plugin_ref.plugin, INVOKE_HOOK): - self._func: Callable[[PluginPayload, PluginContext], Awaitable[PluginResult]] = partial( - plugin_ref.plugin.invoke_hook, hook - ) # type: ignore[attr-defined] - else: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin: {plugin_ref.plugin.name} is not an external plugin", - plugin_name=plugin_ref.plugin.name, - ) - ) diff --git a/cpex/framework/external/mcp/server/__init__.py b/cpex/framework/external/mcp/server/__init__.py deleted file mode 100644 index 95ff2342..00000000 --- a/cpex/framework/external/mcp/server/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/mcp/server/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -External plugins package. -Exposes external plugin components: -- server -""" - -from cpex.framework.external.mcp.server.server import ExternalPluginServer - -__all__ = ["ExternalPluginServer"] diff --git a/cpex/framework/external/mcp/server/runtime.py b/cpex/framework/external/mcp/server/runtime.py deleted file mode 100755 index 5bd592fc..00000000 --- a/cpex/framework/external/mcp/server/runtime.py +++ /dev/null @@ -1,578 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/mcp/server/runtime.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo, Teryl Taylor - -MCP Plugin Runtime using FastMCP with SSL/TLS support. - -This runtime does the following: -- Uses FastMCP from the MCP Python SDK -- Supports both mTLS and non-mTLS configurations -- Reads configuration from PLUGINS_SERVER_* environment variables or uses configurations - the plugin config.yaml -- Implements all plugin hook tools (get_plugin_configs, tool_pre_invoke, etc.) - -Examples: - Create an SSL-capable FastMCP server: - - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="localhost", port=8000) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") - >>> server.settings.host - 'localhost' - >>> server.settings.port - 8000 - - Check SSL configuration returns empty dict when TLS is not configured: - - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="127.0.0.1", port=8000, tls=None) - >>> server = SSLCapableFastMCP(server_config=config, name="NoTLSServer") - >>> ssl_config = server._get_ssl_config() - >>> ssl_config - {} - - Verify server configuration is accessible: - - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="localhost", port=9000) - >>> server = SSLCapableFastMCP(server_config=config, name="ConfigTest") - >>> server.server_config.host - 'localhost' - >>> server.server_config.port - 9000 - - Settings are properly passed to FastMCP: - - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="0.0.0.0", port=8080) - >>> server = SSLCapableFastMCP(server_config=config, name="SettingsTest") - >>> server.settings.host - '0.0.0.0' - >>> server.settings.port - 8080 -""" - -# Standard -import asyncio -import logging -import os -import sys -from typing import Any, Dict, Literal - -import uvicorn - -# Third-Party -from fastapi import Response, status -from mcp.server.fastmcp import FastMCP -from mcp.server.transport_security import TransportSecuritySettings -from prometheus_client import REGISTRY, Gauge, generate_latest - -# First-Party -from cpex.framework import ExternalPluginServer, MCPServerConfig -from cpex.framework.constants import ( - GET_PLUGIN_CONFIG, - GET_PLUGIN_CONFIGS, - INVOKE_HOOK, - MCP_SERVER_INSTRUCTIONS, - MCP_SERVER_NAME, -) - -# Configure logging - respect PLUGINS_LOG_LEVEL environment variable -from cpex.framework.settings import get_settings, get_transport_settings - -settings = get_settings() -log_level_str = settings.log_level -log_level = getattr(logging, log_level_str.upper(), logging.INFO) -logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stderr, -) - -logger = logging.getLogger(__name__) - -SERVER: ExternalPluginServer | None = None - -PLUGIN_INFO = Gauge( - "plugin_info", - "Plugin server information", - ["server_name", "transport", "ssl_enabled"], - registry=REGISTRY, -) - -# Module-level tool functions (extracted for testability) - - -async def get_plugin_configs() -> list[dict]: - """Get the plugin configurations installed on the server. - - Returns: - JSON string containing list of plugin configuration dictionaries. - - Raises: - RuntimeError: If plugin server not initialized. - - Examples: - Function raises RuntimeError when server is not initialized: - - >>> import asyncio - >>> asyncio.run(get_plugin_configs()) # doctest: +SKIP - Traceback (most recent call last): - ... - RuntimeError: Plugin server not initialized - """ - if not SERVER: - raise RuntimeError("Plugin server not initialized") - return await SERVER.get_plugin_configs() - - -async def get_plugin_config(name: str) -> dict: - """Get the plugin configuration for a specific plugin. - - Args: - name: The name of the plugin - - Returns: - JSON string containing plugin configuration dictionary. - - Raises: - RuntimeError: If plugin server not initialized. - - Examples: - Function returns empty dict when result is None: - - >>> result = None - >>> result if result is not None else {} - {} - """ - if not SERVER: - raise RuntimeError("Plugin server not initialized") - result = await SERVER.get_plugin_config(name) - if result is None: - return {} - return result - - -async def invoke_hook(hook_type: str, plugin_name: str, payload: Dict[str, Any], context: Dict[str, Any]) -> dict: - """Execute a hook for a plugin. - - Args: - hook_type: The name or type of the hook. - plugin_name: The name of the plugin to execute - payload: The resource payload to be analyzed - context: Contextual information - - Returns: - Result dictionary with payload, context and any error information. - - Raises: - RuntimeError: If plugin server not initialized. - - Examples: - Function raises RuntimeError when server is not initialized: - - >>> import asyncio - >>> asyncio.run(invoke_hook("hook", "plugin", {}, {})) # doctest: +SKIP - Traceback (most recent call last): - ... - RuntimeError: Plugin server not initialized - """ - if not SERVER: - raise RuntimeError("Plugin server not initialized") - return await SERVER.invoke_hook(hook_type, plugin_name, payload, context) - - -class SSLCapableFastMCP(FastMCP): - """FastMCP server with SSL/TLS support using MCPServerConfig. - - Examples: - Create an SSL-capable FastMCP server: - - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="127.0.0.1", port=8000) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") - >>> server.settings.host - '127.0.0.1' - >>> server.settings.port - 8000 - """ - - def __init__(self, server_config: MCPServerConfig, *args, **kwargs): - """Initialize an SSL capable Fast MCP server. - - Args: - server_config: the MCP server configuration including mTLS information. - *args: Additional positional arguments passed to FastMCP. - **kwargs: Additional keyword arguments passed to FastMCP. - - Examples: - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="0.0.0.0", port=9000) - >>> server = SSLCapableFastMCP(server_config=config, name="PluginServer") - >>> server.server_config.host - '0.0.0.0' - >>> server.server_config.port - 9000 - """ - # Load server config from environment - - self.server_config = server_config - # Override FastMCP settings with our server config - if "host" not in kwargs: - kwargs["host"] = self.server_config.host - if "port" not in kwargs: - kwargs["port"] = self.server_config.port - if self.server_config.uds and kwargs.get("transport_security") is None: - kwargs["transport_security"] = TransportSecuritySettings( - enable_dns_rebinding_protection=True, - allowed_hosts=[ - "127.0.0.1", - "localhost", - "[::1]", - "127.0.0.1:*", - "localhost:*", - "[::1]:*", - ], - allowed_origins=[ - "http://127.0.0.1", - "http://localhost", - "http://[::1]", - "http://127.0.0.1:*", - "http://localhost:*", - "http://[::1]:*", - ], - ) - - super().__init__(*args, **kwargs) - - def _get_ssl_config(self) -> dict: - """Build SSL configuration for uvicorn from MCPServerConfig. - - Returns: - Dictionary of SSL configuration parameters for uvicorn. - - Examples: - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="127.0.0.1", port=8000, tls=None) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") - >>> ssl_config = server._get_ssl_config() - >>> ssl_config - {} - """ - ssl_config = {} - - if self.server_config.tls: - tls = self.server_config.tls - if tls.keyfile and tls.certfile: - ssl_config["ssl_keyfile"] = tls.keyfile - ssl_config["ssl_certfile"] = tls.certfile - - if tls.ca_bundle: - ssl_config["ssl_ca_certs"] = tls.ca_bundle - - ssl_config["ssl_cert_reqs"] = str(tls.ssl_cert_reqs) - - if tls.keyfile_password: - ssl_config["ssl_keyfile_password"] = tls.keyfile_password - - logger.info("SSL/TLS enabled (mTLS)") - logger.info(f" Key: {ssl_config['ssl_keyfile']}") - logger.info(f" Cert: {ssl_config['ssl_certfile']}") - if "ssl_ca_certs" in ssl_config: - logger.info(f" CA: {ssl_config['ssl_ca_certs']}") - logger.info(f" Client cert required: {ssl_config['ssl_cert_reqs'] == 2}") - else: - logger.warning("TLS config present but keyfile/certfile not configured") - else: - logger.info("SSL/TLS not enabled") - - return ssl_config - - async def _start_health_check_server(self, health_port: int) -> None: - """Start a simple HTTP-only health check server on a separate port. - - This allows health checks to work even when the main server uses HTTPS/mTLS. - - Args: - health_port: Port number for the health check server. - - Examples: - Health check endpoint returns expected JSON response: - - >>> import asyncio - >>> from starlette.responses import JSONResponse - >>> from starlette.requests import Request - >>> async def health_check(_request: Request): - ... return JSONResponse({"status": "healthy"}) - >>> response = asyncio.run(health_check(None)) - >>> response.status_code - 200 - """ - # Third-Party - from starlette.applications import Starlette # pylint: disable=import-outside-toplevel - from starlette.requests import Request # pylint: disable=import-outside-toplevel - from starlette.routing import Route # pylint: disable=import-outside-toplevel - - # First-Party - from cpex.framework.utils import ORJSONResponse # pylint: disable=import-outside-toplevel - - async def health_check(_request: Request): - """Health check endpoint for container orchestration. - - Returns: - JSON response with health status. - """ - return ORJSONResponse({"status": "healthy"}) - - async def metrics_endpoint(_request: Request): - """Prometheus metrics endpoint. - - Returns: - JSON response with health status. - - """ - metrics_data = generate_latest(REGISTRY) - return Response(content=metrics_data, media_type="text/plain; version=0.0.4") - - async def metrics_disabled(): - """Returns metrics response when metrics collection is disabled. - - Returns: - Response: HTTP 503 response indicating metrics are disabled. - """ - return Response( - content='{"error": "Metrics collection is disabled"}', - media_type="application/json", - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - routes = [ - Route("/health", health_check, methods=["GET"]), - ] - enable_metrics = os.getenv("ENABLE_METRICS", "true").lower() == "true" - if enable_metrics: - routes.append(Route("/metrics/prometheus", metrics_endpoint, methods=["GET"])) - else: - routes.append(Route("/metrics/prometheus", metrics_disabled, methods=["GET"])) - - # Create a minimal Starlette app with only the health endpoint - health_app = Starlette(routes=routes) - - logger.info(f"Starting HTTP health check server on {self.settings.host}:{health_port}") - config = uvicorn.Config( - app=health_app, - host=self.settings.host, - port=health_port, - log_level="warning", # Reduce noise from health checks - ) - server = uvicorn.Server(config) - await server.serve() - - async def run_streamable_http_async(self) -> None: - """Run the server using StreamableHTTP transport with optional SSL/TLS. - - Examples: - Server uses configured host and port: - - >>> from cpex.framework.models import MCPServerConfig - >>> config = MCPServerConfig(host="0.0.0.0", port=9000) - >>> server = SSLCapableFastMCP(server_config=config, name="HTTPServer") - >>> server.settings.host - '0.0.0.0' - >>> server.settings.port - 9000 - """ - starlette_app = self.streamable_http_app() - - # Add health check endpoint to main app - # Third-Party - from starlette.requests import Request # pylint: disable=import-outside-toplevel - from starlette.routing import Route # pylint: disable=import-outside-toplevel - - # First-Party - from cpex.framework.utils import ORJSONResponse # pylint: disable=import-outside-toplevel - - async def health_check(_request: Request): - """Health check endpoint for container orchestration. - - Returns: - JSON response with health status. - """ - return ORJSONResponse({"status": "healthy"}) - - # Add the health route to the Starlette app - starlette_app.routes.append(Route("/health", health_check, methods=["GET"])) - - async def metrics_endpoint(_request: Request): - """Prometheus metrics endpoint. - - Returns: - text response with metrics detail. - """ - metrics_data = generate_latest(REGISTRY) - return Response(content=metrics_data, media_type="text/plain; version=0.0.4") - - async def metrics_disabled(): - """Returns metrics response when metrics collection is disabled. - - Returns: - Response: HTTP 503 response indicating metrics are disabled. - """ - return Response( - content='{"error": "Metrics collection is disabled"}', - media_type="application/json", - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - # Add the metrics route to the Starlette app - enable_metrics = os.getenv("ENABLE_METRICS", "true").lower() == "true" - if enable_metrics: - starlette_app.routes.append(Route("/metrics/prometheus", metrics_endpoint, methods=["GET"])) - else: - starlette_app.routes.append(Route("/metrics/prometheus", metrics_disabled, methods=["GET"])) - - # Build uvicorn config with optional SSL - ssl_config = self._get_ssl_config() - config_kwargs = { - "app": starlette_app, - "host": self.settings.host, - "port": self.settings.port, - "log_level": self.settings.log_level.lower(), - } - config_kwargs.update(ssl_config) - - if self.server_config.uds: - config_kwargs.pop("host", None) - config_kwargs.pop("port", None) - config_kwargs["uds"] = self.server_config.uds - logger.info(f"Starting plugin server on unix socket {self.server_config.uds}") - else: - logger.info(f"Starting plugin server on {self.settings.host}:{self.settings.port}") - config = uvicorn.Config(**config_kwargs) # type: ignore[arg-type] - server = uvicorn.Server(config) - - # If SSL is enabled, start a separate HTTP health check server - if ssl_config and not self.server_config.uds: - health_port = self.settings.port + 1000 # Use port+1000 for health checks - logger.info(f"SSL enabled - starting separate HTTP health check on port {health_port}") - # Run both servers concurrently - await asyncio.gather(server.serve(), self._start_health_check_server(health_port)) - else: - # Just run the main server (health check is already on it) - await server.serve() - - -async def run() -> None: - """Run the external plugin server with FastMCP. - - Supports both stdio and HTTP transports. Auto-detects transport based on stdin - (if stdin is not a TTY, uses stdio mode), or you can explicitly set PLUGINS_TRANSPORT. - - Reads configuration from PLUGINS_SERVER_* environment variables: - - PLUGINS_TRANSPORT: Transport type - 'stdio' or 'http' (default: auto-detect) - - PLUGINS_SERVER_HOST: Server host (default: 0.0.0.0) - HTTP mode only - - PLUGINS_SERVER_PORT: Server port (default: 8000) - HTTP mode only - - PLUGINS_SERVER_UDS: Unix domain socket path - HTTP mode only (no TLS) - - PLUGINS_SERVER_SSL_ENABLED: Enable SSL/TLS (true/false) - HTTP mode only - - PLUGINS_SERVER_SSL_KEYFILE: Path to server private key - HTTP mode only - - PLUGINS_SERVER_SSL_CERTFILE: Path to server certificate - HTTP mode only - - PLUGINS_SERVER_SSL_CA_CERTS: Path to CA bundle for client verification - HTTP mode only - - PLUGINS_SERVER_SSL_CERT_REQS: Client cert requirement (0=NONE, 1=OPTIONAL, 2=REQUIRED) - HTTP mode only - - Raises: - Exception: If plugin server initialization or execution fails. - - Examples: - SERVER module variable starts as None: - - >>> SERVER is None - True - - FastMCP server names are defined as constants: - - >>> from cpex.framework.constants import MCP_SERVER_NAME - >>> isinstance(MCP_SERVER_NAME, str) - True - >>> len(MCP_SERVER_NAME) > 0 - True - """ - global SERVER # pylint: disable=global-statement - - # Initialize plugin server - SERVER = ExternalPluginServer() - - if not await SERVER.initialize(): - logger.error("Failed to initialize plugin server") - return - - # Determine transport type from environment variable or auto-detect - # Auto-detect: if stdin is not a TTY (i.e., it's being piped), use stdio mode - # First-Party - transport = get_transport_settings().transport - if transport is None: - # Auto-detect based on stdin - if not sys.stdin.isatty(): - transport = "stdio" - logger.info("Auto-detected stdio transport (stdin is not a TTY)") - else: - transport = "http" - else: - transport = transport.lower() - - try: - if transport == "stdio": - # Create basic FastMCP server for stdio (no SSL support needed for stdio) - mcp = FastMCP( - name=MCP_SERVER_NAME, - instructions=MCP_SERVER_INSTRUCTIONS, - ) - - # Register module-level tool functions with FastMCP - mcp.tool(name=GET_PLUGIN_CONFIGS)(get_plugin_configs) - mcp.tool(name=GET_PLUGIN_CONFIG)(get_plugin_config) - mcp.tool(name=INVOKE_HOOK)(invoke_hook) - # set the plugin_info gauge on startup - PLUGIN_INFO.labels(server_name=MCP_SERVER_NAME, transport="stdio", ssl_enabled="false").set(1) - - # Run with stdio transport - logger.info("Starting MCP plugin server with FastMCP (stdio transport)") - await mcp.run_stdio_async() - - else: # http or streamablehttp - server_config: MCPServerConfig = SERVER.get_server_config() - # Create FastMCP server with SSL support - mcp = SSLCapableFastMCP( - server_config, - name=MCP_SERVER_NAME, - instructions=MCP_SERVER_INSTRUCTIONS, - ) - - # Register module-level tool functions with FastMCP - mcp.tool(name=GET_PLUGIN_CONFIGS)(get_plugin_configs) - mcp.tool(name=GET_PLUGIN_CONFIG)(get_plugin_config) - mcp.tool(name=INVOKE_HOOK)(invoke_hook) - # set the plugin_info gauge on startup - ssl_enabled: Literal["true", "false"] = ( - "true" if server_config and server_config.tls is not None else "false" - ) - PLUGIN_INFO.labels(server_name=MCP_SERVER_NAME, transport="http", ssl_enabled=ssl_enabled).set(1) - if server_config: - logger.info( - f"Prometheus metrics available at http://{server_config.host}:{server_config.port}/metrics/prometheus" - ) - # Run with streamable-http transport - logger.info("Starting MCP plugin server with FastMCP (HTTP transport)") - await mcp.run_streamable_http_async() - - except Exception: - logger.exception("Caught error while executing plugin server") - raise - finally: - await SERVER.shutdown() - - -if __name__ == "__main__": - asyncio.run(run()) diff --git a/cpex/framework/external/mcp/server/server.py b/cpex/framework/external/mcp/server/server.py deleted file mode 100644 index cfa681ea..00000000 --- a/cpex/framework/external/mcp/server/server.py +++ /dev/null @@ -1,302 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/mcp/server/server.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo, Teryl Taylor - -Module that contains plugin MCP server code to serve external plugins. - -Examples: - Create an external plugin server with a configuration file: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> server is not None - True - >>> isinstance(server._config_path, str) - True - - Get server configuration with defaults: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> config = server.get_server_config() - >>> config.host == '127.0.0.1' - True - >>> config.port == 8000 - True - - Verify plugin manager is initialized: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> server._plugin_manager is not None - True - >>> server._config is not None - True - - Multiple servers can be created: - - >>> server1 = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> server2 = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml") - >>> server1._config_path != server2._config_path - True - - Configuration is loaded from file: - - >>> import asyncio - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> plugins = asyncio.run(server.get_plugin_configs()) - >>> isinstance(plugins, list) - True - >>> len(plugins) >= 1 - True - - Server configuration defaults are sensible: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> config = server.get_server_config() - >>> isinstance(config.host, str) - True - >>> isinstance(config.port, int) - True - >>> config.port > 0 - True -""" - -# Standard -import logging -import os -from typing import Any, Dict, TypeVar - -# Third-Party -from pydantic import BaseModel - -# First-Party -from cpex.framework.constants import CONTEXT, ERROR, PLUGIN_NAME, RESULT -from cpex.framework.errors import PluginError, convert_exception_to_error -from cpex.framework.loader.config import ConfigLoader -from cpex.framework.manager import PluginManager -from cpex.framework.models import GRPCServerConfig, MCPServerConfig, PluginContext -from cpex.framework.settings import get_config_path_settings - -P = TypeVar("P", bound=BaseModel) - -logger = logging.getLogger(__name__) - - -class ExternalPluginServer: - """External plugin server, providing methods for invoking plugin hooks.""" - - def __init__(self, config_path: str | None = None) -> None: - """Create an external plugin server. - - Args: - config_path: The configuration file path for loading plugins. - If set, this attribute overrides the value in PLUGINS_CONFIG_PATH. - - Examples: - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> server is not None - True - """ - self._config_path = ( - config_path - or get_config_path_settings().config_path - or os.path.join(".", "resources", "plugins", "config.yaml") - ) - self._config = ConfigLoader.load_config(self._config_path, use_jinja=False) - self._plugin_manager = PluginManager(self._config_path) - - async def get_plugin_configs(self) -> list[dict]: - """Return a list of plugin configurations for plugins currently installed on the MCP server. - - Returns: - A list of plugin configurations. - - Examples: - >>> import asyncio - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> plugins = asyncio.run(server.get_plugin_configs()) - >>> len(plugins) > 0 - True - - Returns empty list when no plugins configured: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> server._config.plugins = None - >>> plugins = asyncio.run(server.get_plugin_configs()) - >>> plugins - [] - - Each plugin config is a dictionary: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> plugins = asyncio.run(server.get_plugin_configs()) - >>> all(isinstance(p, dict) for p in plugins) - True - """ - plugins: list[dict] = [] - if self._config.plugins: - for plug in self._config.plugins: - plugins.append(plug.model_dump()) - return plugins - - async def get_plugin_config(self, name: str) -> dict | None: - """Return a plugin configuration give a plugin name. - - Args: - name: The name of the plugin of which to return the plugin configuration. - - Returns: - A plugin configuration dict, or None if not found. - - Examples: - >>> import asyncio - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> c = asyncio.run(server.get_plugin_config(name = "ReplaceBadWordsPlugin")) - >>> c is not None - True - >>> c["name"] == "ReplaceBadWordsPlugin" - True - - Returns None when plugin not found: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> c = asyncio.run(server.get_plugin_config(name="NonExistentPlugin")) - >>> c is None - True - - Case-insensitive plugin name lookup: - - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> c1 = asyncio.run(server.get_plugin_config(name="ReplaceBadWordsPlugin")) - >>> c2 = asyncio.run(server.get_plugin_config(name="replacebadwordsplugin")) - >>> c1 == c2 - True - """ - if self._config.plugins: - for plug in self._config.plugins: - if plug.name.lower() == name.lower(): - return plug.model_dump() - return None - - async def invoke_hook( - self, hook_type: str, plugin_name: str, payload: Dict[str, Any], context: Dict[str, Any] | PluginContext - ) -> dict: - """Invoke a plugin hook. - - Args: - hook_type: The type of hook function to be invoked. - plugin_name: The name of the plugin to execute. - payload: The prompt name and arguments to be analyzed. - context: The contextual and state information required for the execution of the hook. - Can be a dict (for MCP transport) or PluginContext (for gRPC/Unix socket). - - Raises: - ValueError: If unable to retrieve a plugin. - - Returns: - The transformed or filtered response from the plugin hook. - - Examples: - >>> import asyncio - >>> import os - >>> os.environ["PYTHONPATH"] = "." - >>> from cpex.framework import GlobalContext, Plugin, PromptHookType, PromptPrehookPayload, PluginContext, PromptPrehookResult, PluginManager - >>> PluginManager.reset() - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> payload = PromptPrehookPayload(prompt_id="123", name="test_prompt", args={"user": "This is a crap app"}) - >>> context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - >>> initialized = asyncio.run(server.initialize()) - >>> initialized - True - >>> result = asyncio.run(server.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, "ReplaceBadWordsPlugin", payload.model_dump(), context.model_dump())) - >>> result is not None - True - >>> result["result"]["continue_processing"] - True - >>> "yikes" in result["result"]["modified_payload"]["args"]["user"] - True - """ - result_payload: dict[str, Any] = {PLUGIN_NAME: plugin_name} - try: - # Track if input was Pydantic (for optimized response path) - context_is_pydantic = isinstance(context, PluginContext) - _context = context if context_is_pydantic else PluginContext.model_validate(context) - - result = await self._plugin_manager.invoke_hook_for_plugin( - plugin_name, hook_type, payload, _context, payload_as_json=True - ) - - result_payload[RESULT] = result.model_dump() - if not _context.is_empty(): - # Return Pydantic directly if input was Pydantic (avoids extra serialization) - result_payload[CONTEXT] = _context if context_is_pydantic else _context.model_dump() - return result_payload - except PluginError as pe: - result_payload[ERROR] = pe.error - return result_payload - except Exception as ex: - logger.exception(ex) - result_payload[ERROR] = convert_exception_to_error(ex, plugin_name=plugin_name).model_dump() - return result_payload - - async def initialize(self) -> bool: - """Initialize the plugin server. - - Returns: - A boolean indicating the intialization status of the server. - - Examples: - >>> import asyncio - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> result = asyncio.run(server.initialize()) - >>> result - True - >>> asyncio.run(server.shutdown()) - """ - await self._plugin_manager.initialize() - return self._plugin_manager.initialized - - async def shutdown(self) -> None: - """Shutdown the plugin server. - - Examples: - >>> import asyncio - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> asyncio.run(server.initialize()) - True - >>> asyncio.run(server.shutdown()) - """ - if self._plugin_manager.initialized: - await self._plugin_manager.shutdown() - - def get_server_config(self) -> MCPServerConfig: - """Return the configuration for the plugin server. - - Returns: - A server configuration including host, port, and TLS information. - - Examples: - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> config = server.get_server_config() - >>> isinstance(config, MCPServerConfig) - True - >>> config.host - '127.0.0.1' - >>> config.port - 8000 - """ - return self._config.server_settings or MCPServerConfig.from_env() or MCPServerConfig() - - def get_grpc_server_config(self) -> GRPCServerConfig | None: - """Return the gRPC server configuration if defined. - - Returns: - The gRPC server configuration from the config file, or None if not defined. - - Examples: - >>> server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - >>> config = server.get_grpc_server_config() - >>> config is None or isinstance(config, GRPCServerConfig) - True - """ - return self._config.grpc_server_settings diff --git a/cpex/framework/external/mcp/tls_utils.py b/cpex/framework/external/mcp/tls_utils.py deleted file mode 100644 index 78cacbf0..00000000 --- a/cpex/framework/external/mcp/tls_utils.py +++ /dev/null @@ -1,244 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/mcp/tls_utils.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -TLS/SSL utility functions for external MCP plugin connections. - -This module provides utilities for creating and configuring SSL contexts for -secure communication with external MCP plugin servers. It implements the -certificate validation logic that is tested in test_client_certificate_validation.py. - -Examples: - Create a basic SSL context with default settings: - - >>> from cpex.framework.models import MCPClientTLSConfig - >>> import ssl - >>> config = MCPClientTLSConfig() - >>> ctx = create_ssl_context(config, "ExamplePlugin") - >>> ctx.verify_mode == ssl.CERT_REQUIRED - True - - Create an SSL context with hostname verification disabled: - - >>> config = MCPClientTLSConfig(verify=True, check_hostname=False) - >>> ctx = create_ssl_context(config, "NoHostnamePlugin") - >>> ctx.verify_mode == ssl.CERT_REQUIRED - True - >>> ctx.check_hostname - False - - Verify that TLS version is enforced: - - >>> config = MCPClientTLSConfig(verify=True) - >>> ctx = create_ssl_context(config, "VersionTestPlugin") - >>> ctx.minimum_version >= ssl.TLSVersion.TLSv1_2 - True - - All SSL contexts have TLS 1.2 minimum: - - >>> config1 = MCPClientTLSConfig(verify=True) - >>> config2 = MCPClientTLSConfig(verify=False) - >>> ctx1 = create_ssl_context(config1, "Plugin1") - >>> ctx2 = create_ssl_context(config2, "Plugin2") - >>> ctx1.minimum_version == ctx2.minimum_version - True - >>> ctx1.minimum_version.name - 'TLSv1_2' - - Verify mode differs based on configuration: - - >>> config_secure = MCPClientTLSConfig(verify=True) - >>> config_insecure = MCPClientTLSConfig(verify=False) - >>> ctx_secure = create_ssl_context(config_secure, "SecureP") - >>> ctx_insecure = create_ssl_context(config_insecure, "InsecureP") - >>> ctx_secure.verify_mode != ctx_insecure.verify_mode - True - >>> import ssl - >>> ctx_secure.verify_mode == ssl.CERT_REQUIRED - True - >>> ctx_insecure.verify_mode == ssl.CERT_NONE - True -""" - -# Standard -import logging -import ssl - -# First-Party -from cpex.framework.errors import PluginError -from cpex.framework.models import MCPClientTLSConfig, PluginErrorModel - -logger = logging.getLogger(__name__) - - -def create_ssl_context(tls_config: MCPClientTLSConfig, plugin_name: str) -> ssl.SSLContext: - """Create and configure an SSL context for external plugin connections. - - This function implements the SSL/TLS security configuration for connecting to - external MCP plugin servers. It supports both standard TLS and mutual TLS (mTLS) - authentication. - - Security Features Implemented (per Python ssl docs and OpenSSL): - - 1. **Invalid Certificate Rejection**: ssl.create_default_context() with CERT_REQUIRED - automatically validates certificate signatures and chains via OpenSSL. - - 2. **Expired Certificate Handling**: OpenSSL automatically checks notBefore and - notAfter fields per RFC 5280 Section 6. Expired or not-yet-valid certificates - are rejected during the handshake. - - 3. **Certificate Chain Validation**: Full chain validation up to a trusted CA. - Each certificate in the chain is verified for validity period, signature, etc. - - 4. **Hostname Verification**: When check_hostname is enabled, the certificate's - Subject Alternative Name (SAN) or Common Name (CN) must match the hostname. - - 5. **MITM Prevention**: Via mutual authentication when client certificates are - provided (mTLS mode). - - Args: - tls_config: TLS configuration containing CA bundle, client certs, and verification settings - plugin_name: Name of the plugin (for error messages) - - Returns: - Configured SSLContext ready for use with httpx or other SSL connections - - Raises: - PluginError: If SSL context configuration fails - - Examples: - Create SSL context with verification enabled (default secure mode): - - >>> from cpex.framework.models import MCPClientTLSConfig - >>> tls_config = MCPClientTLSConfig(verify=True) - >>> ssl_context = create_ssl_context(tls_config, "TestPlugin") - >>> ssl_context.verify_mode == 2 # ssl.CERT_REQUIRED - True - >>> ssl_context.check_hostname - True - - Create SSL context with verification disabled (development/testing): - - >>> tls_config = MCPClientTLSConfig(verify=False, check_hostname=False) - >>> ssl_context = create_ssl_context(tls_config, "DevPlugin") - >>> ssl_context.verify_mode == 0 # ssl.CERT_NONE - True - >>> ssl_context.check_hostname - False - - Verify TLS 1.2 minimum version enforcement: - - >>> tls_config = MCPClientTLSConfig(verify=True) - >>> ssl_context = create_ssl_context(tls_config, "SecurePlugin") - >>> ssl_context.minimum_version.name - 'TLSv1_2' - - Mixed security settings (verify enabled, hostname check disabled): - - >>> tls_config = MCPClientTLSConfig(verify=True, check_hostname=False) - >>> ssl_context = create_ssl_context(tls_config, "MixedPlugin") - >>> ssl_context.verify_mode == 2 # ssl.CERT_REQUIRED - True - >>> ssl_context.check_hostname - False - - Default configuration is secure: - - >>> tls_config = MCPClientTLSConfig() - >>> ssl_context = create_ssl_context(tls_config, "DefaultPlugin") - >>> ssl_context.verify_mode == 2 # ssl.CERT_REQUIRED - True - >>> ssl_context.check_hostname - True - >>> ssl_context.minimum_version.name - 'TLSv1_2' - - Test error handling with invalid certificate file: - - >>> import tempfile - >>> import os - >>> tmp_dir = tempfile.mkdtemp() - >>> bad_cert = os.path.join(tmp_dir, "bad.pem") - >>> with open(bad_cert, 'w') as f: - ... _ = f.write("INVALID CERT") - >>> tls_config = MCPClientTLSConfig(certfile=bad_cert, keyfile=bad_cert, verify=False) - >>> try: - ... ssl_context = create_ssl_context(tls_config, "BadCertPlugin") - ... except PluginError as e: - ... "Failed to configure SSL context" in e.error.message - True - - Verify logging occurs for different configurations: - - >>> import logging - >>> tls_config = MCPClientTLSConfig(verify=False) - >>> ssl_context = create_ssl_context(tls_config, "LogTestPlugin") - >>> ssl_context is not None - True - """ - try: - # Create SSL context with secure defaults - # Per Python docs: "The settings are chosen by the ssl module, and usually - # represent a higher security level than when calling the SSLContext - # constructor directly." - # This sets verify_mode to CERT_REQUIRED by default, which enables: - # - Certificate signature validation - # - Certificate chain validation up to trusted CA - # - Automatic expiration checking (notBefore/notAfter per RFC 5280) - ssl_context = ( - ssl.create_default_context() - ) # NOSONAR as this will fail check_hostname from NOT tls_config.verify - - # Enforce TLS 1.2 or higher for security - ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 - - if not tls_config.verify: - # Disable certificate verification (not recommended for production) - logger.warning( - f"Certificate verification disabled for plugin '{plugin_name}'. This is not recommended for production use." - ) - ssl_context.check_hostname = False # NOSONAR as this is specifically NOT tls_config.verify - ssl_context.verify_mode = ssl.CERT_NONE # nosec B502 # noqa: DUO122 # NOSONAR as this is specifically NOT tls_config.verify - else: - # Enable strict certificate verification (production mode) - # Load CA certificate bundle for server certificate validation - if tls_config.ca_bundle: - # This CA bundle will be used to validate the server's certificate - # OpenSSL will check: - # - Certificate is signed by a trusted CA in this bundle - # - Certificate hasn't expired (notAfter > now) - # - Certificate is already valid (notBefore < now) - # - Certificate chain is complete and valid - ssl_context.load_verify_locations(cafile=tls_config.ca_bundle) - - # Hostname verification - # When enabled, certificate's SAN or CN must match the server hostname - if not tls_config.check_hostname: - logger.warning( - f"Hostname verification disabled for plugin '{plugin_name}'. This increases risk of MITM attacks." - ) - ssl_context.check_hostname = False - - # Load client certificate for mTLS (mutual authentication) - # If provided, the client will authenticate itself to the server - if tls_config.certfile: - ssl_context.load_cert_chain( - certfile=tls_config.certfile, - keyfile=tls_config.keyfile, - password=tls_config.keyfile_password, - ) - logger.debug(f"mTLS enabled for plugin '{plugin_name}' with client certificate: {tls_config.certfile}") - - # Log security configuration - logger.debug( - f"SSL context created for plugin '{plugin_name}': verify_mode={ssl_context.verify_mode}, check_hostname={ssl_context.check_hostname}, minimum_version={ssl_context.minimum_version}" - ) - - return ssl_context - - except Exception as exc: - error_msg = f"Failed to configure SSL context for plugin '{plugin_name}': {exc}" - logger.error(error_msg) - raise PluginError(error=PluginErrorModel(message=error_msg, plugin_name=plugin_name)) from exc diff --git a/cpex/framework/external/proto_convert.py b/cpex/framework/external/proto_convert.py deleted file mode 100644 index 5159b6c7..00000000 --- a/cpex/framework/external/proto_convert.py +++ /dev/null @@ -1,258 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/proto_convert.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Conversion utilities between Pydantic models and protobuf messages. - -This module provides efficient conversion functions that use explicit protobuf -messages where possible, falling back to Struct for dynamic fields. -""" - -# pylint: disable=no-member - -# Standard - -# Third-Party -from google.protobuf import json_format - -# First-Party -from cpex.framework.external.grpc.proto import plugin_service_pb2 -from cpex.framework.models import GlobalContext as PydanticGlobalContext -from cpex.framework.models import PluginContext as PydanticPluginContext -from cpex.framework.models import ( - PluginResult, -) -from cpex.framework.models import PluginViolation as PydanticPluginViolation - - -def pydantic_global_context_to_proto(ctx: PydanticGlobalContext) -> plugin_service_pb2.GlobalContext: - """Convert Pydantic GlobalContext to protobuf GlobalContext. - - Args: - ctx: The Pydantic GlobalContext model. - - Returns: - The protobuf GlobalContext message. - """ - proto_ctx = plugin_service_pb2.GlobalContext( - request_id=ctx.request_id, - server_id=ctx.server_id or "", - tenant_id=ctx.tenant_id or "", - ) - - # Handle user field (can be string or dict) - if ctx.user is not None: - if isinstance(ctx.user, str): - proto_ctx.user_string = ctx.user - elif isinstance(ctx.user, dict): - json_format.ParseDict(ctx.user, proto_ctx.user_struct) - - # Handle dynamic fields with Struct - if ctx.metadata: - json_format.ParseDict(ctx.metadata, proto_ctx.metadata) - if ctx.state: - json_format.ParseDict(ctx.state, proto_ctx.state) - - return proto_ctx - - -def proto_global_context_to_pydantic(proto_ctx: plugin_service_pb2.GlobalContext) -> PydanticGlobalContext: - """Convert protobuf GlobalContext to Pydantic GlobalContext. - - Args: - proto_ctx: The protobuf GlobalContext message. - - Returns: - The Pydantic GlobalContext model. - """ - # Handle user field - user = None - if proto_ctx.HasField("user_string"): - user = proto_ctx.user_string - elif proto_ctx.HasField("user_struct"): - user = json_format.MessageToDict(proto_ctx.user_struct) - - return PydanticGlobalContext( - request_id=proto_ctx.request_id, - server_id=proto_ctx.server_id or None, - tenant_id=proto_ctx.tenant_id or None, - user=user, - metadata=json_format.MessageToDict(proto_ctx.metadata) if proto_ctx.metadata.fields else {}, - state=json_format.MessageToDict(proto_ctx.state) if proto_ctx.state.fields else {}, - ) - - -def pydantic_context_to_proto(ctx: PydanticPluginContext) -> plugin_service_pb2.PluginContext: - """Convert Pydantic PluginContext to protobuf PluginContext. - - Args: - ctx: The Pydantic PluginContext model. - - Returns: - The protobuf PluginContext message. - """ - proto_ctx = plugin_service_pb2.PluginContext( - global_context=pydantic_global_context_to_proto(ctx.global_context), - ) - - if ctx.state: - json_format.ParseDict(ctx.state, proto_ctx.state) - if ctx.metadata: - json_format.ParseDict(ctx.metadata, proto_ctx.metadata) - - return proto_ctx - - -def proto_context_to_pydantic(proto_ctx: plugin_service_pb2.PluginContext) -> PydanticPluginContext: - """Convert protobuf PluginContext to Pydantic PluginContext. - - Args: - proto_ctx: The protobuf PluginContext message. - - Returns: - The Pydantic PluginContext model. - """ - return PydanticPluginContext( - global_context=proto_global_context_to_pydantic(proto_ctx.global_context), - state=json_format.MessageToDict(proto_ctx.state) if proto_ctx.state.fields else {}, - metadata=json_format.MessageToDict(proto_ctx.metadata) if proto_ctx.metadata.fields else {}, - ) - - -def proto_context_to_dict(proto_ctx: plugin_service_pb2.PluginContext) -> dict: - """Convert protobuf PluginContext directly to dict (for server use). - - This avoids the intermediate Pydantic model when only a dict is needed. - - Args: - proto_ctx: The protobuf PluginContext message. - - Returns: - Dictionary representation of the context. - """ - gc = proto_ctx.global_context - - # Handle user field - user = None - if gc.HasField("user_string"): - user = gc.user_string - elif gc.HasField("user_struct"): - user = json_format.MessageToDict(gc.user_struct) - - return { - "global_context": { - "request_id": gc.request_id, - "server_id": gc.server_id or None, - "tenant_id": gc.tenant_id or None, - "user": user, - "metadata": json_format.MessageToDict(gc.metadata) if gc.metadata.fields else {}, - "state": json_format.MessageToDict(gc.state) if gc.state.fields else {}, - }, - "state": json_format.MessageToDict(proto_ctx.state) if proto_ctx.state.fields else {}, - "metadata": json_format.MessageToDict(proto_ctx.metadata) if proto_ctx.metadata.fields else {}, - } - - -def pydantic_violation_to_proto(violation: PydanticPluginViolation) -> plugin_service_pb2.PluginViolation: - """Convert Pydantic PluginViolation to protobuf PluginViolation. - - Args: - violation: The Pydantic PluginViolation model. - - Returns: - The protobuf PluginViolation message. - """ - proto_violation = plugin_service_pb2.PluginViolation( - reason=violation.reason, - description=violation.description, - code=violation.code, - plugin_name=violation.plugin_name or "", - mcp_error_code=violation.mcp_error_code or 0, - ) - - if violation.details: - json_format.ParseDict(violation.details, proto_violation.details) - - return proto_violation - - -def proto_violation_to_pydantic(proto_violation: plugin_service_pb2.PluginViolation) -> PydanticPluginViolation: - """Convert protobuf PluginViolation to Pydantic PluginViolation. - - Args: - proto_violation: The protobuf PluginViolation message. - - Returns: - The Pydantic PluginViolation model. - """ - violation = PydanticPluginViolation( - reason=proto_violation.reason, - description=proto_violation.description, - code=proto_violation.code, - details=json_format.MessageToDict(proto_violation.details) if proto_violation.details.fields else {}, - mcp_error_code=proto_violation.mcp_error_code if proto_violation.mcp_error_code else None, - ) - if proto_violation.plugin_name: - violation.plugin_name = proto_violation.plugin_name - return violation - - -def pydantic_result_to_proto_base(result: PluginResult) -> plugin_service_pb2.PluginResultBase: - """Convert common PluginResult fields to protobuf PluginResultBase. - - Args: - result: The Pydantic PluginResult model. - - Returns: - The protobuf PluginResultBase message with common fields. - """ - proto_result = plugin_service_pb2.PluginResultBase( - continue_processing=result.continue_processing, - ) - - if result.violation: - proto_result.violation.CopyFrom(pydantic_violation_to_proto(result.violation)) - - if result.metadata: - json_format.ParseDict(result.metadata, proto_result.metadata) - - return proto_result - - -def update_pydantic_result_from_proto_base( - result: PluginResult, - proto_base: plugin_service_pb2.PluginResultBase, -) -> None: - """Update a Pydantic PluginResult with values from PluginResultBase. - - Args: - result: The Pydantic PluginResult to update. - proto_base: The protobuf PluginResultBase with common fields. - """ - result.continue_processing = proto_base.continue_processing - - if proto_base.HasField("violation"): - result.violation = proto_violation_to_pydantic(proto_base.violation) - - if proto_base.metadata.fields: - result.metadata = json_format.MessageToDict(proto_base.metadata) - - -def update_pydantic_context_from_proto( - ctx: PydanticPluginContext, - proto_ctx: plugin_service_pb2.PluginContext, -) -> None: - """Update a Pydantic PluginContext in-place from protobuf PluginContext. - - Args: - ctx: The Pydantic PluginContext to update. - proto_ctx: The protobuf PluginContext with updated values. - """ - ctx.state = json_format.MessageToDict(proto_ctx.state) if proto_ctx.state.fields else {} - ctx.metadata = json_format.MessageToDict(proto_ctx.metadata) if proto_ctx.metadata.fields else {} - - # Update global context state - if proto_ctx.global_context.state.fields: - ctx.global_context.state = json_format.MessageToDict(proto_ctx.global_context.state) diff --git a/cpex/framework/external/unix/__init__.py b/cpex/framework/external/unix/__init__.py deleted file mode 100644 index 9a0c9820..00000000 --- a/cpex/framework/external/unix/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/unix/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Raw Unix socket transport for external plugins. - -This transport provides high-performance IPC for local plugins using -length-prefixed protobuf messages over Unix domain sockets. -""" - -from cpex.framework.external.unix.client import UnixSocketExternalPlugin - -__all__ = ["UnixSocketExternalPlugin"] diff --git a/cpex/framework/external/unix/client.py b/cpex/framework/external/unix/client.py deleted file mode 100644 index 9a90eebb..00000000 --- a/cpex/framework/external/unix/client.py +++ /dev/null @@ -1,355 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/unix/client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unix socket client for external plugins. - -This module provides a high-performance client for communicating with -external plugins over Unix domain sockets using length-prefixed protobuf -messages. - -Examples: - Create and use a Unix socket plugin client: - - >>> from cpex.framework.external.unix.client import UnixSocketExternalPlugin - >>> from cpex.framework.models import PluginConfig, UnixSocketClientConfig - - >>> config = PluginConfig( - ... name="MyPlugin", - ... kind="external", - ... hooks=["tool_pre_invoke"], - ... unix_socket=UnixSocketClientConfig(path="/tmp/plugin.sock"), - ... ) - >>> plugin = UnixSocketExternalPlugin(config) - >>> # await plugin.initialize() - >>> # result = await plugin.invoke_hook(hook_type, payload, context) -""" - -# pylint: disable=no-member,no-name-in-module - -# Standard -import asyncio -import logging -from typing import Any, Optional - -# Third-Party -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Struct - -# First-Party -from cpex.framework.base import Plugin -from cpex.framework.errors import PluginError, convert_exception_to_error -from cpex.framework.external.grpc.proto import plugin_service_pb2 -from cpex.framework.external.proto_convert import pydantic_context_to_proto, update_pydantic_context_from_proto -from cpex.framework.external.unix.protocol import read_message, write_message_async -from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginResult - -logger = logging.getLogger(__name__) - - -class UnixSocketExternalPlugin(Plugin): - """External plugin client using raw Unix domain sockets. - - This client provides high-performance IPC for local plugins using - length-prefixed protobuf messages. It includes automatic reconnection - with configurable retry logic. - - Attributes: - config: The plugin configuration. - - Examples: - >>> from cpex.framework.models import PluginConfig, UnixSocketClientConfig - >>> config = PluginConfig( - ... name="TestPlugin", - ... kind="external", - ... hooks=["tool_pre_invoke"], - ... unix_socket=UnixSocketClientConfig(path="/tmp/test.sock"), - ... ) - >>> plugin = UnixSocketExternalPlugin(config) - >>> plugin.name - 'TestPlugin' - """ - - def __init__(self, config: PluginConfig) -> None: - """Initialize the Unix socket plugin client. - - Args: - config: The plugin configuration with unix_socket settings. - - Raises: - PluginError: If unix_socket configuration is missing. - """ - super().__init__(config) - - if not config.unix_socket: - raise PluginError( - error=PluginErrorModel( - message="The unix_socket section must be defined for Unix socket plugin", plugin_name=config.name - ) - ) - - self._socket_path = config.unix_socket.path - self._reconnect_attempts = config.unix_socket.reconnect_attempts - self._reconnect_delay = config.unix_socket.reconnect_delay - self._timeout = config.unix_socket.timeout - - self._reader: Optional[asyncio.StreamReader] = None - self._writer: Optional[asyncio.StreamWriter] = None - self._connected = False - self._lock = asyncio.Lock() - - @property - def connected(self) -> bool: - """Check if the client is connected. - - Returns: - bool: True if connected and writer is active, False otherwise. - """ - return self._connected and self._writer is not None and not self._writer.is_closing() - - async def _connect(self) -> None: - """Establish connection to the Unix socket server. - - Raises: - PluginError: If connection fails. - """ - try: - self._reader, self._writer = await asyncio.open_unix_connection(self._socket_path) - self._connected = True - logger.debug("Connected to Unix socket: %s", self._socket_path) - except OSError as e: - self._connected = False - raise PluginError( - error=PluginErrorModel(message=f"Failed to connect to {self._socket_path}: {e}", plugin_name=self.name) - ) from e - - async def _disconnect(self) -> None: - """Close the connection.""" - if self._writer: - try: - self._writer.close() - await self._writer.wait_closed() - except Exception: # nosec B110 - cleanup code, exceptions should not propagate - pass - self._writer = None - self._reader = None - self._connected = False - - async def _reconnect(self) -> None: - """Attempt to reconnect with retry logic. - - Raises: - PluginError: If all reconnection attempts fail. - """ - await self._disconnect() - - last_error: Optional[Exception] = None - for attempt in range(1, self._reconnect_attempts + 1): - try: - logger.debug("Reconnection attempt %d/%d to %s", attempt, self._reconnect_attempts, self._socket_path) - await self._connect() - logger.info("Reconnected to %s on attempt %d", self._socket_path, attempt) - return - except PluginError as e: - last_error = e - if attempt < self._reconnect_attempts: - await asyncio.sleep(self._reconnect_delay * attempt) # Exponential backoff - - raise PluginError( - error=PluginErrorModel( - message=f"Failed to reconnect after {self._reconnect_attempts} attempts: {last_error}", - plugin_name=self.name, - ) - ) - - async def _send_request( - self, request: plugin_service_pb2.InvokeHookRequest - ) -> plugin_service_pb2.InvokeHookResponse: - """Send a request and receive response, with reconnection on failure. - - Args: - request: The protobuf request to send. - - Returns: - The protobuf response. - - Raises: - PluginError: If sending fails after reconnection attempts. - """ - request_bytes = request.SerializeToString() - - async with self._lock: - for attempt in range(self._reconnect_attempts + 1): - try: - if not self.connected: - await self._reconnect() - - # Send request - await write_message_async(self._writer, request_bytes) - - # Read response - response_bytes = await read_message(self._reader, timeout=self._timeout) - - # Parse response - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - return response - - except asyncio.TimeoutError as e: - logger.warning("Request timed out after %s seconds", self._timeout) - raise PluginError( - error=PluginErrorModel( - message=f"Request timed out after {self._timeout}s", plugin_name=self.name - ) - ) from e - - except (OSError, asyncio.IncompleteReadError, BrokenPipeError) as e: - logger.warning("Connection error on attempt %d: %s", attempt + 1, e) - self._connected = False - - if attempt < self._reconnect_attempts: - await asyncio.sleep(self._reconnect_delay * (attempt + 1)) - continue - raise PluginError( - error=PluginErrorModel( - message=f"Request failed after {self._reconnect_attempts + 1} attempts: {e}", - plugin_name=self.name, - ) - ) from e - - # Should not reach here - raise PluginError(error=PluginErrorModel(message="Unexpected state in _send_request", plugin_name=self.name)) - - async def initialize(self) -> None: - """Initialize the plugin client by connecting to the server. - - This establishes the Unix socket connection and optionally - fetches the remote plugin configuration. - - Raises: - PluginError: If initial connection fails. - """ - logger.info("Initializing Unix socket plugin: %s -> %s", self.name, self._socket_path) - - try: - await self._connect() - except PluginError: - raise - except Exception as e: - logger.exception(e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - - # Optionally fetch remote config to verify connection - try: - request = plugin_service_pb2.GetPluginConfigRequest(name=self.name) - request_bytes = request.SerializeToString() - - await write_message_async(self._writer, request_bytes) - response_bytes = await read_message(self._reader, timeout=self._timeout) - - response = plugin_service_pb2.GetPluginConfigResponse() - response.ParseFromString(response_bytes) - - if response.found: - logger.debug("Remote plugin config verified for %s", self.name) - else: - logger.warning("Plugin %s not found on remote server", self.name) - - except Exception as e: - logger.warning("Could not verify remote plugin config: %s", e) - # Continue anyway - the plugin might still work - - logger.info("Unix socket plugin initialized: %s", self.name) - - async def shutdown(self) -> None: - """Shutdown the plugin client and close the connection.""" - logger.info("Shutting down Unix socket plugin: %s", self.name) - await self._disconnect() - - async def invoke_hook( - self, - hook_type: str, - payload: Any, - context: PluginContext, - ) -> PluginResult: - """Invoke a plugin hook over the Unix socket connection. - - Args: - hook_type: The type of hook to invoke (e.g., "tool_pre_invoke"). - payload: The hook payload (will be serialized to protobuf Struct). - context: The plugin context. - - Returns: - The plugin result. - - Raises: - PluginError: If the request fails after retries or hook type is invalid. - """ - # Get the result type from the global registry - registry = get_hook_registry() - result_type = registry.get_result_type(hook_type) - if not result_type: - raise PluginError( - error=PluginErrorModel( - message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name - ) - ) - - # Convert payload to Struct (still polymorphic) - payload_struct = Struct() - if hasattr(payload, "model_dump"): - json_format.ParseDict(payload.model_dump(), payload_struct) - else: - json_format.ParseDict(payload, payload_struct) - - # Convert context to explicit proto message (faster than Struct) - context_proto = pydantic_context_to_proto(context) - - # Build request - request = plugin_service_pb2.InvokeHookRequest( - hook_type=hook_type, - plugin_name=self.name, - payload=payload_struct, - context=context_proto, - ) - - try: - # Send request and get response - response = await self._send_request(request) - - # Handle error response - if response.HasField("error") and response.error.message: - error = PluginErrorModel( - message=response.error.message, - plugin_name=response.error.plugin_name or self.name, - code=response.error.code, - mcp_error_code=response.error.mcp_error_code, - ) - if response.error.HasField("details"): - error.details = json_format.MessageToDict(response.error.details) - raise PluginError(error=error) - - # Update context if modified (using explicit proto message) - if response.HasField("context"): - update_pydantic_context_from_proto(context, response.context) - - # Parse and return result - if response.HasField("result"): - result_dict = json_format.MessageToDict(response.result) - return result_type.model_validate(result_dict) - - raise PluginError( - error=PluginErrorModel( - message="Received invalid response from Unix socket plugin server", - plugin_name=self.name, - ) - ) - - except PluginError: - raise - except Exception as e: - logger.exception(e) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) diff --git a/cpex/framework/external/unix/protocol.py b/cpex/framework/external/unix/protocol.py deleted file mode 100644 index 06f5b8c4..00000000 --- a/cpex/framework/external/unix/protocol.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/unix/protocol.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Protocol helpers for length-prefixed message framing over Unix sockets. - -This module provides simple, efficient message framing using a 4-byte -big-endian length prefix followed by the message payload. - -Wire format: [4-byte length (big-endian)][payload bytes] - -Examples: - Writing a message: - - >>> import asyncio - >>> from cpex.framework.external.unix.protocol import write_message, read_message - - Reading and writing work as inverse operations: - - >>> # In an async context with reader/writer streams - >>> # write_message(writer, b"hello") - >>> # data = await read_message(reader) # returns b"hello" -""" - -# Standard -import asyncio -import struct -from typing import Optional - -# 4-byte big-endian unsigned int for length prefix -LENGTH_FORMAT = ">I" -LENGTH_SIZE = 4 - -# Maximum message size (16 MB) to prevent memory exhaustion -MAX_MESSAGE_SIZE = 16 * 1024 * 1024 - - -class ProtocolError(Exception): - """Raised when a protocol-level error occurs.""" - - -async def read_message(reader: asyncio.StreamReader, timeout: Optional[float] = None) -> bytes: - """Read a length-prefixed message from the stream. - - Args: - reader: The async stream reader. - timeout: Optional timeout in seconds for the read operation. - - Returns: - The message payload as bytes. - - Raises: - ProtocolError: If the message is malformed or too large. - asyncio.IncompleteReadError: If the connection is closed mid-read. - asyncio.TimeoutError: If the read times out. - - Examples: - >>> # In an async context - >>> # data = await read_message(reader) - >>> # data = await read_message(reader, timeout=5.0) - """ - - async def _read() -> bytes: - """Read and validate a length-prefixed message from the stream. - - Returns: - The data read from the message stream as bytes. - - Raises: - ProtocolError: If the message is too large. - - """ - # Read 4-byte length prefix - length_bytes = await reader.readexactly(LENGTH_SIZE) - length = struct.unpack(LENGTH_FORMAT, length_bytes)[0] - - # Validate message size - if length > MAX_MESSAGE_SIZE: - raise ProtocolError(f"Message size {length} exceeds maximum {MAX_MESSAGE_SIZE}") - - if length == 0: - return b"" - - # Read the message payload - return await reader.readexactly(length) - - if timeout is not None: - return await asyncio.wait_for(_read(), timeout=timeout) - return await _read() - - -def write_message(writer: asyncio.StreamWriter, data: bytes) -> None: - """Write a length-prefixed message to the stream. - - This writes the message to the buffer but does not flush. Call - `await writer.drain()` after writing to ensure delivery. - - Args: - writer: The async stream writer. - data: The message payload to write. - - Raises: - ProtocolError: If the message is too large. - - Examples: - >>> # In an async context - >>> # write_message(writer, b"hello") - >>> # await writer.drain() - """ - if len(data) > MAX_MESSAGE_SIZE: - raise ProtocolError(f"Message size {len(data)} exceeds maximum {MAX_MESSAGE_SIZE}") - - length = struct.pack(LENGTH_FORMAT, len(data)) - writer.write(length + data) - - -async def write_message_async(writer: asyncio.StreamWriter, data: bytes, drain: bool = True) -> None: - """Write a length-prefixed message and optionally drain. - - Args: - writer: The async stream writer. - data: The message payload to write. - drain: Whether to drain the write buffer (default True). - - Raises: - ProtocolError: If the message is too large. - - Examples: - >>> # In an async context - >>> # await write_message_async(writer, b"hello") - """ - write_message(writer, data) - if drain: - await writer.drain() diff --git a/cpex/framework/external/unix/server/__init__.py b/cpex/framework/external/unix/server/__init__.py deleted file mode 100644 index daf57bd7..00000000 --- a/cpex/framework/external/unix/server/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/unix/server/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unix socket server package for external plugins. -""" - -from cpex.framework.external.unix.server.server import UnixSocketPluginServer - -__all__ = ["UnixSocketPluginServer"] diff --git a/cpex/framework/external/unix/server/runtime.py b/cpex/framework/external/unix/server/runtime.py deleted file mode 100644 index fed4bbf8..00000000 --- a/cpex/framework/external/unix/server/runtime.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/unix/server/runtime.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Entry point for running the Unix socket plugin server. - -Usage: - python -m cpex.framework.external.unix.server.runtime - -Environment variables: - PLUGINS_CONFIG_PATH: Path to plugin configuration file - PLUGINS_UNIX_SOCKET_PATH: Path for Unix socket (default: /tmp/mcpgateway-plugins.sock) - -Examples: - Run with default settings: - - $ PLUGINS_CONFIG_PATH=plugins/config.yaml python -m cpex.framework.external.unix.server.runtime - - Run with custom socket path: - - $ PLUGINS_UNIX_SOCKET_PATH=/tmp/my-plugins.sock python -m cpex.framework.external.unix.server.runtime -""" - -# Standard -import asyncio -import logging -import os -import sys - -# First-Party -from cpex.framework.external.unix.server.server import run_server -from cpex.framework.settings import get_settings - -# Configure logging - respect PLUGINS_LOG_LEVEL environment variable -settings = get_settings() -log_level_str = settings.log_level -log_level = getattr(logging, log_level_str.upper(), logging.INFO) -logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stderr, # Log to stderr to keep stdout clean for coordination -) - -logger = logging.getLogger(__name__) - - -async def run() -> None: - """Main entry point for the Unix socket server.""" - s = get_settings() - config_path = s.config_path or os.path.join(".", "resources", "plugins", "config.yaml") - socket_path = s.unix_socket_path or "/tmp/mcpgateway-plugins.sock" # nosec B108 - configurable via env var - - logger.info("Starting Unix socket plugin server") - logger.info(" Config: %s", config_path) - logger.info(" Socket: %s", socket_path) - - await run_server(config_path=config_path, socket_path=socket_path) - - -def main() -> None: - """CLI entry point.""" - try: - asyncio.run(run()) - except KeyboardInterrupt: - logger.info("Server interrupted") - except Exception as e: - logger.exception("Server error: %s", e) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/cpex/framework/external/unix/server/server.py b/cpex/framework/external/unix/server/server.py deleted file mode 100644 index 826d2348..00000000 --- a/cpex/framework/external/unix/server/server.py +++ /dev/null @@ -1,419 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/external/unix/server/server.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unix socket server for external plugins. - -This module provides a high-performance server that handles plugin requests -over Unix domain sockets using length-prefixed protobuf messages. - -Examples: - Run the server: - - >>> import asyncio - >>> from cpex.framework.external.unix.server.server import UnixSocketPluginServer - - >>> async def main(): - ... server = UnixSocketPluginServer( - ... config_path="plugins/config.yaml", - ... socket_path="/tmp/plugin.sock", - ... ) - ... await server.start() - ... # Server runs until stopped - ... await server.stop() - - >>> # asyncio.run(main()) -""" - -# pylint: disable=no-member,no-name-in-module - -# Standard -import asyncio -import logging -import os -import signal -from typing import Optional - -# Third-Party -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Struct - -# First-Party -from cpex.framework.external.grpc.proto import plugin_service_pb2 -from cpex.framework.external.mcp.server.server import ExternalPluginServer -from cpex.framework.external.proto_convert import ( - proto_context_to_pydantic, - pydantic_context_to_proto, -) -from cpex.framework.external.unix.protocol import ProtocolError, read_message, write_message_async -from cpex.framework.models import PluginContext - -logger = logging.getLogger(__name__) - - -class UnixSocketPluginServer: - """Unix socket server for handling external plugin requests. - - This server listens on a Unix domain socket and handles plugin - requests using length-prefixed protobuf messages. It wraps the - ExternalPluginServer for actual plugin execution. - - Attributes: - socket_path: Path to the Unix socket file. - - Examples: - >>> server = UnixSocketPluginServer( - ... config_path="plugins/config.yaml", - ... socket_path="/tmp/test.sock", - ... ) - >>> server.socket_path - '/tmp/test.sock' - """ - - def __init__( - self, - config_path: str, - socket_path: str = "/tmp/mcpgateway-plugins.sock", # nosec B108 - configurable default - ) -> None: - """Initialize the Unix socket server. - - Args: - config_path: Path to the plugin configuration file. - socket_path: Path for the Unix socket file. - """ - self._config_path = config_path - self._socket_path = socket_path - self._plugin_server: Optional[ExternalPluginServer] = None - self._server: Optional[asyncio.Server] = None - self._running = False - - @property - def socket_path(self) -> str: - """Get the socket path. - - Returns: - str: The Unix socket file path. - """ - return self._socket_path - - @property - def running(self) -> bool: - """Check if the server is running. - - Returns: - bool: True if the server is running, False otherwise. - """ - return self._running - - async def _handle_client( - self, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - ) -> None: - """Handle a client connection. - - Args: - reader: The stream reader for the client. - writer: The stream writer for the client. - """ - peer = writer.get_extra_info("peername") or "unknown" - logger.debug("Client connected: %s", peer) - - try: - while self._running: - try: - # Read request with timeout - data = await read_message(reader, timeout=300.0) # 5 min timeout - except asyncio.TimeoutError: - logger.debug("Client %s timed out", peer) - break - except asyncio.IncompleteReadError: - # Client disconnected - break - except ProtocolError as e: - logger.warning("Protocol error from %s: %s", peer, e) - break - - # Determine message type and handle - response_bytes = await self._handle_message(data) - - # Send response - try: - await write_message_async(writer, response_bytes) - except (OSError, BrokenPipeError): - logger.debug("Client %s disconnected during write", peer) - break - - except Exception as e: - logger.exception("Error handling client %s: %s", peer, e) - finally: - logger.debug("Client disconnected: %s", peer) - try: - writer.close() - await writer.wait_closed() - except Exception: # nosec B110 - cleanup code, exceptions should not propagate - pass - - async def _handle_message(self, data: bytes) -> bytes: - """Handle a single message and return the response. - - Args: - data: The raw message bytes. - - Returns: - The serialized response bytes. - """ - # Try to parse as InvokeHookRequest first (most common) - try: - request = plugin_service_pb2.InvokeHookRequest() - request.ParseFromString(data) - - if request.hook_type and request.plugin_name: - return await self._handle_invoke_hook(request) - except Exception: # nosec B110 - protobuf parse attempt, try next message type - pass - - # Try GetPluginConfigRequest - try: - request = plugin_service_pb2.GetPluginConfigRequest() - request.ParseFromString(data) - - if request.name: - return await self._handle_get_plugin_config(request) - except Exception: # nosec B110 - protobuf parse attempt, try next message type - pass - - # Try GetPluginConfigsRequest - try: - request = plugin_service_pb2.GetPluginConfigsRequest() - request.ParseFromString(data) - # This request has no required fields, so check if data is minimal - if len(data) <= 2: # Empty or near-empty message - return await self._handle_get_plugin_configs(request) - except Exception: # nosec B110 - protobuf parse attempt, fall through to error - pass - - # Unknown message type - logger.warning("Unknown message type, length=%d", len(data)) - error_response = plugin_service_pb2.InvokeHookResponse() - error_response.error.message = "Unknown message type" - error_response.error.code = "UNKNOWN_MESSAGE" - return error_response.SerializeToString() - - async def _handle_invoke_hook( - self, - request: plugin_service_pb2.InvokeHookRequest, - ) -> bytes: - """Handle an InvokeHook request. - - Args: - request: The InvokeHookRequest. - - Returns: - Serialized InvokeHookResponse. - """ - response = plugin_service_pb2.InvokeHookResponse(plugin_name=request.plugin_name) - - try: - # Convert payload to dict (still polymorphic) - payload_dict = json_format.MessageToDict(request.payload) - - # Convert explicit PluginContext proto directly to Pydantic - context_pydantic = proto_context_to_pydantic(request.context) - - # Invoke the hook (passing Pydantic context directly, no dict conversion) - result = await self._plugin_server.invoke_hook( - hook_type=request.hook_type, - plugin_name=request.plugin_name, - payload=payload_dict, - context=context_pydantic, - ) - - # Build response - if "error" in result: - error_obj = result["error"] - if hasattr(error_obj, "model_dump"): - error_dict = error_obj.model_dump() - else: - error_dict = error_obj - - response.error.message = error_dict.get("message", "Unknown error") - response.error.plugin_name = error_dict.get("plugin_name", "unknown") - response.error.code = error_dict.get("code", "") - response.error.mcp_error_code = error_dict.get("mcp_error_code", -32603) - else: - if "result" in result: - json_format.ParseDict(result["result"], response.result) - if "context" in result: - ctx = result["context"] - # Handle both Pydantic (optimized path) and dict (MCP compat) - if isinstance(ctx, PluginContext): - response.context.CopyFrom(pydantic_context_to_proto(ctx)) - else: - updated_context = PluginContext.model_validate(ctx) - response.context.CopyFrom(pydantic_context_to_proto(updated_context)) - - except Exception as e: - logger.exception("Error invoking hook: %s", e) - response.error.message = str(e) - response.error.code = "INTERNAL_ERROR" - response.error.mcp_error_code = -32603 - - return response.SerializeToString() - - async def _handle_get_plugin_config( - self, - request: plugin_service_pb2.GetPluginConfigRequest, - ) -> bytes: - """Handle a GetPluginConfig request. - - Args: - request: The GetPluginConfigRequest. - - Returns: - Serialized GetPluginConfigResponse. - """ - response = plugin_service_pb2.GetPluginConfigResponse() - - try: - config = await self._plugin_server.get_plugin_config(request.name) - - if config: - response.found = True - json_format.ParseDict(config, response.config) - else: - response.found = False - - except Exception as e: - logger.exception("Error getting plugin config: %s", e) - response.found = False - - return response.SerializeToString() - - async def _handle_get_plugin_configs( - self, - _request: plugin_service_pb2.GetPluginConfigsRequest, - ) -> bytes: - """Handle a GetPluginConfigs request. - - Args: - _request: The GetPluginConfigsRequest (unused, included for API consistency). - - Returns: - Serialized GetPluginConfigsResponse. - """ - response = plugin_service_pb2.GetPluginConfigsResponse() - - try: - configs = await self._plugin_server.get_plugin_configs() - - for config in configs: - config_struct = Struct() - json_format.ParseDict(config, config_struct) - response.configs.append(config_struct) - - except Exception as e: - logger.exception("Error getting plugin configs: %s", e) - - return response.SerializeToString() - - async def start(self) -> None: - """Start the Unix socket server. - - This initializes the plugin server and starts listening for - connections on the Unix socket. - """ - logger.info("Starting Unix socket plugin server on %s", self._socket_path) - - # Clean up old socket file - if os.path.exists(self._socket_path): - os.unlink(self._socket_path) - - # Initialize the plugin server - self._plugin_server = ExternalPluginServer(config_path=self._config_path) - await self._plugin_server.initialize() - - # Create the Unix socket server - self._server = await asyncio.start_unix_server( - self._handle_client, - path=self._socket_path, - ) - - # Set restrictive permissions on the socket file (owner read/write only) - if os.path.exists(self._socket_path): - os.chmod(self._socket_path, 0o600) - - self._running = True - logger.info("Unix socket plugin server started on %s", self._socket_path) - - async def serve_forever(self) -> None: - """Serve requests until stopped. - - Raises: - RuntimeError: If the server has not been started. - """ - if not self._server: - raise RuntimeError("Server not started. Call start() first.") - - async with self._server: - await self._server.serve_forever() - - async def stop(self) -> None: - """Stop the Unix socket server.""" - logger.info("Stopping Unix socket plugin server") - self._running = False - - if self._server: - self._server.close() - await self._server.wait_closed() - self._server = None - - if self._plugin_server: - await self._plugin_server.shutdown() - self._plugin_server = None - - # Clean up socket file - if os.path.exists(self._socket_path): - try: - os.unlink(self._socket_path) - except OSError: - pass - - logger.info("Unix socket plugin server stopped") - - -async def run_server( - config_path: str, - socket_path: str = "/tmp/mcpgateway-plugins.sock", # nosec B108 - configurable default -) -> None: - """Run the Unix socket server until interrupted. - - Args: - config_path: Path to the plugin configuration file. - socket_path: Path for the Unix socket file. - """ - server = UnixSocketPluginServer(config_path=config_path, socket_path=socket_path) - - # Set up signal handlers - loop = asyncio.get_running_loop() - stop_event = asyncio.Event() - - def signal_handler() -> None: - """Handle SIGINT/SIGTERM by setting the stop event.""" - logger.info("Received shutdown signal") - stop_event.set() - - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, signal_handler) - - await server.start() - - # Signal ready (for parent process coordination) - print("READY", flush=True) - - # Wait for shutdown signal - await stop_event.wait() - - await server.stop() diff --git a/cpex/framework/hooks/__init__.py b/cpex/framework/hooks/__init__.py deleted file mode 100644 index fb378f4a..00000000 --- a/cpex/framework/hooks/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Plugins hooks package. -Exposes predefined hooks for plugins -""" diff --git a/cpex/framework/hooks/agents.py b/cpex/framework/hooks/agents.py deleted file mode 100644 index 97c668b4..00000000 --- a/cpex/framework/hooks/agents.py +++ /dev/null @@ -1,164 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/agents.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Fred Araujo - -Pydantic models for agent plugins. -This module implements the pydantic models associated with -the base plugin layer including configurations, and contexts. -""" - -# Standard -import warnings -from enum import Enum -from typing import Any, Dict, List, Optional - -# Third-Party -from pydantic import Field, field_validator - -# First-Party -from cpex.framework.hooks.http import HttpHeaderPayload -from cpex.framework.models import PluginPayload, PluginResult -from cpex.framework.protocols import MessageLike # noqa: F401 # pylint: disable=unused-import -from cpex.framework.utils import coerce_messages - - -class AgentHookType(str, Enum): - """Agent hook points. - - Attributes: - AGENT_PRE_INVOKE: Before agent invocation. - AGENT_POST_INVOKE: After agent responds. - - Examples: - >>> AgentHookType.AGENT_PRE_INVOKE - - >>> AgentHookType.AGENT_PRE_INVOKE.value - 'agent_pre_invoke' - >>> AgentHookType('agent_post_invoke') - - >>> list(AgentHookType) - [, ] - """ - - AGENT_PRE_INVOKE = "agent_pre_invoke" - AGENT_POST_INVOKE = "agent_post_invoke" - - -class AgentPreInvokePayload(PluginPayload): - """Agent payload for pre-invoke hook. - - Attributes: - agent_id: The agent identifier (can be modified for routing). - messages: Conversation messages (accepts any MessageLike-satisfying objects). - tools: Optional list of tools available to agent. - headers: Optional HTTP headers. - model: Optional model override. - system_prompt: Optional system instructions. - parameters: Optional LLM parameters (temperature, max_tokens, etc.). - - Examples: - >>> payload = AgentPreInvokePayload(agent_id="agent-123", messages=[]) - >>> payload.agent_id - 'agent-123' - >>> payload.messages - [] - >>> payload.tools is None - True - """ - - agent_id: str - messages: List[Any] # Elements satisfy MessageLike protocol (role, content attributes) - tools: Optional[List[str]] = None - headers: Optional[HttpHeaderPayload] = None - model: Optional[str] = None - system_prompt: Optional[str] = None - parameters: Optional[Dict[str, Any]] = Field(default_factory=dict) - - @field_validator("headers", mode="before") - @classmethod - def _warn_headers_deprecated(cls, v: object) -> object: - """Emit deprecation warning for headers field.""" - if v is not None: - warnings.warn( - "AgentPreInvokePayload.headers is deprecated; " - "use extensions.http.headers instead. " - "This field will be removed in a future release.", - DeprecationWarning, - stacklevel=4, - ) - return v - - @field_validator("messages", mode="before") - @classmethod - def _coerce_messages(cls, v: Any) -> Any: - """Convert nested dicts in messages list to objects with attribute access. - - Args: - v: The raw messages value to coerce. - - Returns: - The coerced messages list. - """ - return coerce_messages(v) - - -class AgentPostInvokePayload(PluginPayload): - """Agent payload for post-invoke hook. - - Attributes: - agent_id: The agent identifier. - messages: Response messages from agent (accepts any MessageLike-satisfying objects). - tool_calls: Optional tool invocations made by agent. - - Examples: - >>> payload = AgentPostInvokePayload(agent_id="agent-123", messages=[]) - >>> payload.agent_id - 'agent-123' - >>> payload.messages - [] - >>> payload.tool_calls is None - True - """ - - agent_id: str - messages: List[Any] # Elements satisfy MessageLike protocol (role, content attributes) - tool_calls: Optional[List[Dict[str, Any]]] = None - - @field_validator("messages", mode="before") - @classmethod - def _coerce_messages(cls, v: Any) -> Any: - """Convert nested dicts in messages list to objects with attribute access. - - Args: - v: The raw messages value to coerce. - - Returns: - The coerced messages list. - """ - return coerce_messages(v) - - -AgentPreInvokeResult = PluginResult[AgentPreInvokePayload] -AgentPostInvokeResult = PluginResult[AgentPostInvokePayload] - - -def _register_agent_hooks() -> None: - """Register agent hooks in the global registry. - - This is called lazily to avoid circular import issues. - """ - # Import here to avoid circular dependency at module load time - # First-Party - from cpex.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel - - registry = get_hook_registry() - - # Only register if not already registered (idempotent) - if not registry.is_registered(AgentHookType.AGENT_PRE_INVOKE): - registry.register_hook(AgentHookType.AGENT_PRE_INVOKE, AgentPreInvokePayload, AgentPreInvokeResult) - registry.register_hook(AgentHookType.AGENT_POST_INVOKE, AgentPostInvokePayload, AgentPostInvokeResult) - - -_register_agent_hooks() diff --git a/cpex/framework/hooks/http.py b/cpex/framework/hooks/http.py deleted file mode 100644 index ce1f3fd4..00000000 --- a/cpex/framework/hooks/http.py +++ /dev/null @@ -1,251 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/http.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Pydantic models for http hooks and payloads. -""" - -# Standard -import warnings -from enum import Enum - -# Third-Party -from pydantic import RootModel, field_validator - -# First-Party -from cpex.framework.models import PluginPayload, PluginResult - - -class HttpHeaderPayload(RootModel[dict[str, str]], PluginPayload): - """An HTTP dictionary of headers used in the pre/post HTTP forwarding hooks.""" - - def __iter__(self): # type: ignore[no-untyped-def] - """Custom iterator function to override root attribute. - - Returns: - A custom iterator for header dictionary. - """ - return iter(self.root) - - def __getitem__(self, item: str) -> str: - """Custom getitem function to override root attribute. - - Args: - item: The http header key. - - Returns: - A custom accesser for the header dictionary. - """ - return self.root[item] - - def __setitem__(self, key: str, value: str) -> None: - """Mutation helper — operates on the underlying dict. - - .. warning:: - Because ``PluginPayload`` is frozen, this should only be used - on freshly-created copies (e.g. inside ``model_copy``). The - executor deep-copies payloads before handing them to plugins, - so in-place writes on the copy are safe. - - Args: - key: The http header key. - value: The http header value to be set. - """ - self.root[key] = value - - def __len__(self) -> int: - """Custom len function to override root attribute. - - Returns: - The len of the header dictionary. - """ - return len(self.root) - - -HttpHeaderPayloadResult = PluginResult[HttpHeaderPayload] - - -class HttpHookType(str, Enum): - """Hook types for HTTP request processing and authentication. - - These hooks allow plugins to: - 1. Transform request headers before processing (middleware layer) - 2. Implement custom user authentication systems (auth layer) - 3. Check and grant permissions (RBAC layer) - 4. Process responses after request completion (middleware layer) - """ - - HTTP_PRE_REQUEST = "http_pre_request" - HTTP_POST_REQUEST = "http_post_request" - HTTP_AUTH_RESOLVE_USER = "http_auth_resolve_user" - HTTP_AUTH_CHECK_PERMISSION = "http_auth_check_permission" - - -class HttpPreRequestPayload(PluginPayload): - """Payload for HTTP pre-request hook (middleware layer). - - This payload contains immutable request metadata and a copy of headers - that plugins can inspect. Invoked before any authentication processing. - Plugins return only modified headers via PluginResult[HttpHeaderPayload]. - - Attributes: - path: HTTP path being requested. - method: HTTP method (GET, POST, etc.). - client_host: Client IP address (if available). - client_port: Client port (if available). - headers: Copy of HTTP headers that plugins can inspect and modify. - """ - - path: str - method: str - client_host: str | None = None - client_port: int | None = None - headers: HttpHeaderPayload - - @field_validator("headers", mode="before") - @classmethod - def _warn_headers_deprecated(cls, v: object) -> object: - """Emit deprecation warning for headers field.""" - if v is not None: - warnings.warn( - "HttpPreRequestPayload.headers is deprecated; " - "use extensions.http.headers instead. " - "This field will be removed in a future release.", - DeprecationWarning, - stacklevel=4, - ) - return v - - -class HttpPostRequestPayload(HttpPreRequestPayload): - """Payload for HTTP post-request hook (middleware layer). - - Extends HttpPreRequestPayload with response information. - Invoked after request processing is complete. - Plugins can inspect response headers and status codes. - - Attributes: - response_headers: Response headers from the request (if available). - status_code: HTTP status code from the response (if available). - """ - - response_headers: HttpHeaderPayload | None = None - status_code: int | None = None - - -class HttpAuthResolveUserPayload(PluginPayload): - """Payload for custom user authentication hook (auth layer). - - Invoked inside get_current_user() to allow plugins to provide - custom authentication mechanisms (LDAP, mTLS, external auth, etc.). - Plugins return an authenticated user via PluginResult[dict]. - - Attributes: - credentials: The HTTP authorization credentials from bearer_scheme (if present). - headers: Full request headers for custom auth extraction. - client_host: Client IP address (if available). - client_port: Client port (if available). - """ - - credentials: dict | None = None # HTTPAuthorizationCredentials serialized - headers: HttpHeaderPayload - client_host: str | None = None - client_port: int | None = None - - @field_validator("headers", mode="before") - @classmethod - def _warn_headers_deprecated(cls, v: object) -> object: - """Emit deprecation warning for headers field.""" - if v is not None: - warnings.warn( - "HttpAuthResolveUserPayload.headers is deprecated; " - "use extensions.http.headers instead. " - "This field will be removed in a future release.", - DeprecationWarning, - stacklevel=4, - ) - return v - - -class HttpAuthCheckPermissionPayload(PluginPayload): - """Payload for permission checking hook (RBAC layer). - - Invoked before RBAC permission checks to allow plugins to: - - Grant/deny permissions based on custom logic (e.g., token-based auth) - - Bypass RBAC for certain authentication methods - - Add additional permission checks (e.g., time-based, IP-based) - - Implement custom authorization logic - - Attributes: - user_email: Email of the authenticated user - permission: Required permission being checked (e.g., "tools.read", "servers.write") - resource_type: Type of resource being accessed (e.g., "tool", "server", "prompt") - team_id: Team context for the permission check (if applicable) - is_admin: Whether the user has admin privileges - auth_method: Authentication method used (e.g., "simple_token", "jwt", "oauth") - client_host: Client IP address for IP-based permission checks - user_agent: User agent string for device-based permission checks - """ - - user_email: str - permission: str - resource_type: str | None = None - team_id: str | None = None - is_admin: bool = False - auth_method: str | None = None - client_host: str | None = None - user_agent: str | None = None - - -class HttpAuthCheckPermissionResultPayload(PluginPayload): - """Result payload for permission checking hook. - - Plugins return this to indicate whether permission should be granted. - - Attributes: - granted: Whether permission is granted (True) or denied (False) - reason: Optional reason for the decision (for logging/auditing) - """ - - granted: bool - reason: str | None = None - - -# Type aliases for hook results -HttpPreRequestResult = PluginResult[HttpHeaderPayload] -HttpPostRequestResult = PluginResult[HttpHeaderPayload] -HttpAuthResolveUserResult = PluginResult[dict] # Returns user dict (EmailUser serialized) -HttpAuthCheckPermissionResult = PluginResult[HttpAuthCheckPermissionResultPayload] - - -def _register_http_auth_hooks() -> None: - """Register HTTP authentication and request hooks in the global registry. - - This is called lazily to avoid circular import issues. - Registers four hook types: - - HTTP_PRE_REQUEST: Transform headers before authentication (middleware) - - HTTP_POST_REQUEST: Inspect response after request completion (middleware) - - HTTP_AUTH_RESOLVE_USER: Custom user authentication (auth layer) - - HTTP_AUTH_CHECK_PERMISSION: Custom permission checking (RBAC layer) - """ - # Import here to avoid circular dependency at module load time - # First-Party - from cpex.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel - - registry = get_hook_registry() - - # Only register if not already registered (idempotent) - if not registry.is_registered(HttpHookType.HTTP_PRE_REQUEST): - registry.register_hook(HttpHookType.HTTP_PRE_REQUEST, HttpPreRequestPayload, HttpPreRequestResult) - registry.register_hook(HttpHookType.HTTP_POST_REQUEST, HttpPostRequestPayload, HttpPostRequestResult) - registry.register_hook( - HttpHookType.HTTP_AUTH_RESOLVE_USER, HttpAuthResolveUserPayload, HttpAuthResolveUserResult - ) - registry.register_hook( - HttpHookType.HTTP_AUTH_CHECK_PERMISSION, HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResult - ) - - -_register_http_auth_hooks() diff --git a/cpex/framework/hooks/identity.py b/cpex/framework/hooks/identity.py deleted file mode 100644 index 1872eb0c..00000000 --- a/cpex/framework/hooks/identity.py +++ /dev/null @@ -1,293 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/identity.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Hook definitions for identity resolution and token delegation. - -Two hooks, two phases: -- IdentityResolve: inbound — decode token, verify, map to SubjectExtension -- TokenDelegate: outbound — exchange token for downstream tool credential - -See: docs/delegation-hooks-design.md - -These hooks produce CMF types (SubjectExtension, DelegationExtension) -and complement the legacy HTTP auth hooks in http.py which operate -on flat strings and dicts. -""" - -# Standard -from enum import Enum -from typing import Any - -# Third-Party -from pydantic import BaseModel, ConfigDict, Field, SecretStr - -# First-Party -from cpex.framework.extensions.delegation import DelegationExtension -from cpex.framework.extensions.security import SubjectExtension -from cpex.framework.models import PluginPayload, PluginResult - -# --------------------------------------------------------------------------- -# Hook Types -# --------------------------------------------------------------------------- - - -class IdentityHookType(str, Enum): - """Identity and delegation hook points. - - Attributes: - IDENTITY_RESOLVE: Inbound — decode and validate token, - produce SubjectExtension. - TOKEN_DELEGATE: Outbound — exchange/mint token for - downstream tool invocation. - - Examples: - >>> IdentityHookType.IDENTITY_RESOLVE - - >>> IdentityHookType.TOKEN_DELEGATE.value - 'token_delegate' - """ - - IDENTITY_RESOLVE = "identity_resolve" - TOKEN_DELEGATE = "token_delegate" - - -# --------------------------------------------------------------------------- -# IdentityResolve — Inbound -# --------------------------------------------------------------------------- - - -class IdentityPayload(PluginPayload): - """Payload for the identity resolution hook. - - Carries the raw credential extracted from the inbound request. - The hook implementation decodes, validates, and maps it to a - SubjectExtension. - - Attributes: - raw_token: The raw token string (JWT, opaque, API key, etc.). - source: How the credential was extracted. - headers: Full HTTP headers for custom auth extraction. - client_host: Client IP address (if available). - client_port: Client port (if available). - - Examples: - >>> payload = IdentityPayload( - ... raw_token="eyJhbGciOi...", - ... source="bearer", - ... headers={"authorization": "Bearer eyJhbGciOi..."}, - ... ) - >>> payload.source - 'bearer' - >>> str(payload.raw_token) - '**********' - >>> payload.raw_token.get_secret_value() - 'eyJhbGciOi...' - """ - - raw_token: SecretStr = Field(description="Raw credential string. Redacted on serialization.") - source: str = Field( - default="bearer", - description="Credential source: bearer, mtls, api_key, custom.", - ) - headers: dict[str, str] = Field( - default_factory=dict, - description="Full HTTP headers for custom auth extraction.", - ) - client_host: str | None = Field(default=None, description="Client IP address.") - client_port: int | None = Field(default=None, description="Client port.") - - -class IdentityResult(PluginPayload): - """Result of identity resolution — returned as modified_payload. - - Either provides a resolved SubjectExtension, or rejects with - a status code and reason. The framework validates the result - and seals the SubjectExtension as immutable. - - Extends PluginPayload so it can be carried as the modified_payload - in PluginResult[IdentityResult]. This follows the same pattern as - HttpAuthCheckPermissionResultPayload. - - Attributes: - subject: The resolved identity. None if rejected. - delegation: Initial delegation state from act claims. - rejected: Whether the identity was rejected. - reject_status: HTTP status code for rejection (401 or 403). - reject_reason: Human-readable rejection reason. - raw_claims: Full decoded claims for audit/policy (optional). - - Examples: - >>> result = IdentityResult( - ... subject=SubjectExtension(id="alice@corp.com", type="user"), - ... ) - >>> result.rejected - False - - >>> rejected = IdentityResult( - ... rejected=True, - ... reject_status=401, - ... reject_reason="Token expired", - ... ) - """ - - subject: SubjectExtension | None = Field(default=None, description="Resolved identity.") - delegation: DelegationExtension | None = Field( - default=None, - description="Initial delegation state (from act claims in JWT).", - ) - rejected: bool = Field(default=False, description="Whether the identity was rejected.") - reject_status: int = Field(default=401, description="HTTP status code for rejection.") - reject_reason: str = Field(default="", description="Rejection reason.") - raw_claims: dict[str, Any] = Field( - default_factory=dict, - description="Full decoded token claims (for audit/policy).", - ) - - -IdentityResolveResult = PluginResult[IdentityResult] - - -# --------------------------------------------------------------------------- -# TokenDelegate — Outbound -# --------------------------------------------------------------------------- - - -class AttenuationConfig(BaseModel): - """Configuration for token scope attenuation from DSL route config. - - Attributes: - capabilities: Specific capabilities to grant. - resource_template: URI template with argument substitution. - actions: Allowed actions on the resource. - ttl_seconds: Token lifetime override. - - Examples: - >>> config = AttenuationConfig( - ... capabilities=["read:compensation"], - ... resource_template="hr://employees/{{ args.employee_id }}", - ... actions=["read"], - ... ttl_seconds=60, - ... ) - """ - - model_config = ConfigDict(frozen=True) - - capabilities: list[str] = Field(default_factory=list) - resource_template: str | None = None - actions: list[str] = Field(default_factory=list) - ttl_seconds: int | None = None - - -class DelegationPayload(PluginPayload): - """Payload for the token delegation hook. - - Carries the target tool information and security profile. - The hook implementation exchanges/mints a token for the target. - Subject and existing delegation chain are read from the CMF - message extensions (not duplicated here). - - Attributes: - target_name: Tool, agent, or resource being called. - target_type: Entity type: tool, agent, resource, service. - target_audience: Audience URI for the target (from config). - required_permissions: From ObjectSecurityProfile.permissions. - trust_domain: From ObjectSecurityProfile.trust_domain. - auth_enforced_by: Who enforces auth: caller, target, or both. - route_attenuation: Scope attenuation config from DSL route. - bearer_token: The caller's current bearer token (for exchange). - - Examples: - >>> payload = DelegationPayload( - ... target_name="get_compensation", - ... target_type="tool", - ... required_permissions=["read:compensation"], - ... auth_enforced_by="target", - ... bearer_token="eyJhbGciOi...", - ... ) - """ - - target_name: str = Field(description="Tool/agent/resource being called.") - target_type: str = Field(default="tool", description="Entity type.") - target_audience: str | None = Field(default=None, description="Audience URI.") - required_permissions: list[str] = Field(default_factory=list, description="Required permissions.") - trust_domain: str | None = Field(default=None, description="Trust domain.") - auth_enforced_by: str = Field(default="caller", description="Auth enforcement: caller, target, both.") - route_attenuation: AttenuationConfig | None = Field(default=None, description="Scope attenuation config.") - bearer_token: SecretStr | None = Field( - default=None, description="Caller's current bearer token. Redacted on serialization." - ) - - -class DelegationResult(PluginPayload): - """Result of token delegation — returned as modified_payload. - - The delegated token is returned separately (never stored in - Extensions). The delegation_update is merged into Extensions - by the framework. - - Extends PluginPayload so it can be carried as the modified_payload - in PluginResult[DelegationResult]. - - Attributes: - delegated_token: The credential for the downstream target. - delegation_update: Updated DelegationExtension (merged by framework). - forwarded_headers: Additional headers for the downstream request. - cache_key: Token cache key (for reuse). - cache_ttl: Seconds to cache the token. - - Examples: - >>> result = DelegationResult( - ... delegated_token="eyJhbGciOi...", - ... forwarded_headers={"Authorization": "Bearer eyJhbGciOi..."}, - ... ) - """ - - delegated_token: str | None = Field(default=None, description="Credential for downstream.") - delegation_update: DelegationExtension = Field( - default_factory=DelegationExtension, - description="Updated delegation chain.", - ) - forwarded_headers: dict[str, str] = Field( - default_factory=dict, - description="Additional headers for downstream.", - ) - cache_key: str | None = Field(default=None, description="Token cache key.") - cache_ttl: int | None = Field(default=None, description="Cache TTL in seconds.") - - -TokenDelegateResult = PluginResult[DelegationResult] - - -# --------------------------------------------------------------------------- -# Hook Registration -# --------------------------------------------------------------------------- - - -def _register_identity_hooks() -> None: - """Register identity hooks in the global registry. - - Called at module load time. Idempotent. - """ - from cpex.framework.hooks.registry import get_hook_registry - - registry = get_hook_registry() - - if not registry.is_registered(IdentityHookType.IDENTITY_RESOLVE): - registry.register_hook( - IdentityHookType.IDENTITY_RESOLVE, - IdentityPayload, - IdentityResolveResult, - ) - - if not registry.is_registered(IdentityHookType.TOKEN_DELEGATE): - registry.register_hook( - IdentityHookType.TOKEN_DELEGATE, - DelegationPayload, - TokenDelegateResult, - ) - - -_register_identity_hooks() diff --git a/cpex/framework/hooks/message.py b/cpex/framework/hooks/message.py deleted file mode 100644 index b07b1c98..00000000 --- a/cpex/framework/hooks/message.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/message.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Hook definitions for CMF Message evaluation. - -Provides a unified entry point for policy evaluation on messages -flowing through the system. Plugins receive a MessagePayload -wrapping the CMF Message and can use Message.iter_views() for -granular per-content-part inspection. -""" - -# Standard -from enum import Enum - -# Third-Party -from pydantic import Field - -# First-Party -from cpex.framework.cmf.message import Message -from cpex.framework.models import PluginPayload, PluginResult - - -class MessageHookType(str, Enum): - """Message hook points — metadata on MessagePayload. - - The hook type indicates *where* in the pipeline the evaluation - is happening. This is carried as metadata on the MessagePayload - so plugins can inspect it, but is NOT the hook type used for - dispatch. See CmfHookType for dispatch hook types. - - Attributes: - EVALUATE: Generic message evaluation. - LLM_INPUT: Before model/LLM call (user messages going to LLM). - LLM_OUTPUT: After model/LLM call (LLM response). - TOOL_PRE_INVOKE: Before tool execution (tool call arguments). - TOOL_POST_INVOKE: After tool execution (tool result). - 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. - - Examples: - >>> MessageHookType.EVALUATE - - >>> MessageHookType.LLM_INPUT - - """ - - EVALUATE = "evaluate" - LLM_INPUT = "llm_input" - LLM_OUTPUT = "llm_output" - TOOL_PRE_INVOKE = "tool_pre_invoke" - TOOL_POST_INVOKE = "tool_post_invoke" - PROMPT_PRE_FETCH = "prompt_pre_fetch" - PROMPT_POST_FETCH = "prompt_post_fetch" - RESOURCE_PRE_FETCH = "resource_pre_fetch" - RESOURCE_POST_FETCH = "resource_post_fetch" - - -class CmfHookType(str, Enum): - """CMF hook types — dispatch targets for CMF-based plugins. - - These are the hook types that CMF plugins register for. They - parallel the legacy hook types (tool_pre_invoke, etc.) but use - MessagePayload instead of typed payloads like ToolPreInvokePayload. - - This enables a clean migration path: - - Legacy plugins register for "tool_pre_invoke" and get ToolPreInvokePayload - - CMF plugins register for "cmf.tool_pre_invoke" and get MessagePayload - - The gateway fires both at the same interception point - - The gateway converts legacy payloads to CMF Messages at each point. - - Examples: - >>> CmfHookType.TOOL_PRE_INVOKE - - >>> CmfHookType.TOOL_PRE_INVOKE.value - 'cmf.tool_pre_invoke' - """ - - TOOL_PRE_INVOKE = "cmf.tool_pre_invoke" - TOOL_POST_INVOKE = "cmf.tool_post_invoke" - LLM_INPUT = "cmf.llm_input" - LLM_OUTPUT = "cmf.llm_output" - RESOURCE_PRE_FETCH = "cmf.resource_pre_fetch" - RESOURCE_POST_FETCH = "cmf.resource_post_fetch" - PROMPT_PRE_FETCH = "cmf.prompt_pre_fetch" - PROMPT_POST_FETCH = "cmf.prompt_post_fetch" - - -class MessagePayload(PluginPayload): - """Payload for message evaluation hooks. - - Wraps a CMF Message for processing through the plugin pipeline. - Plugins access the message and use iter_views() for per-content-part - policy evaluation. - - Attributes: - message: The CMF message to evaluate. - hook: The hook location where this evaluation is happening. - - Examples: - >>> from cpex.framework.cmf.message import Message, Role, TextContent - >>> msg = Message( - ... role=Role.USER, - ... content=[TextContent(text="Hello")], - ... ) - >>> payload = MessagePayload( - ... message=msg, hook=MessageHookType.LLM_INPUT - ... ) - >>> payload.hook - - """ - - message: Message = Field(description="The CMF message to evaluate.") - hook: MessageHookType = Field( - default=MessageHookType.EVALUATE, - description="The hook location where this evaluation is happening.", - ) - - -MessageResult = PluginResult[MessagePayload] -"""Result type for message evaluation hooks.""" - - -def _register_message_hooks() -> None: - """Register message hooks in the global registry. - - Called at module load time. Idempotent — skips registration - if the hook is already registered. - - Registers both the generic EVALUATE hook and all CMF-specific hooks. - """ - # First-Party - from cpex.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel - - registry = get_hook_registry() - - # Generic message evaluation hook (legacy) - if not registry.is_registered(MessageHookType.EVALUATE): - registry.register_hook(MessageHookType.EVALUATE, MessagePayload, MessageResult) - - # CMF-specific hooks — same payload/result type, different dispatch points - for cmf_hook in CmfHookType: - if not registry.is_registered(cmf_hook): - registry.register_hook(cmf_hook, MessagePayload, MessageResult) - - -_register_message_hooks() diff --git a/cpex/framework/hooks/policies.py b/cpex/framework/hooks/policies.py deleted file mode 100644 index 6a33f766..00000000 --- a/cpex/framework/hooks/policies.py +++ /dev/null @@ -1,134 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/policies.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Hook payload policy types and utilities. - -The framework provides the types and utilities for controlled payload -modification; the gateway defines the actual concrete policies. - -Examples: - >>> from cpex.framework.hooks.policies import HookPayloadPolicy, apply_policy - >>> policy = HookPayloadPolicy(writable_fields=frozenset({"name", "args"})) - >>> sorted(policy.writable_fields) - ['args', 'name'] -""" - -# Standard -import logging -from dataclasses import dataclass -from enum import Enum -from typing import Any, Optional - -# Third-Party -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - - -class DefaultHookPolicy(str, Enum): - """Controls behavior for hooks without an explicit policy. - - Attributes: - ALLOW: Accept all modifications (backwards compatible). - DENY: Reject all modifications (strict mode). - - Examples: - >>> DefaultHookPolicy.ALLOW - - >>> DefaultHookPolicy.DENY.value - 'deny' - >>> DefaultHookPolicy('allow') - - """ - - ALLOW = "allow" - DENY = "deny" - - -@dataclass(frozen=True) -class HookPayloadPolicy: - """Defines which payload fields plugins are allowed to modify. - - Attributes: - writable_fields: The set of field names that plugins may change. - - Examples: - >>> policy = HookPayloadPolicy(writable_fields=frozenset({"name", "args"})) - >>> "name" in policy.writable_fields - True - >>> "secret" in policy.writable_fields - False - """ - - writable_fields: frozenset[str] - - -_SENTINEL = object() - - -def apply_policy( - original: BaseModel, - modified: BaseModel, - policy: HookPayloadPolicy, - *, - apply_to: Optional[BaseModel] = None, -) -> Optional[BaseModel]: - """Apply policy-based controlled merge. - - Only fields listed in ``policy.writable_fields`` are accepted from - *modified*; all other changes are silently discarded. - - Args: - original: The baseline payload to diff against (what the plugin received). - modified: The payload returned by the plugin. - policy: The policy defining which fields are writable. - apply_to: The target payload to apply accepted changes to. When - ``None`` (the default), changes are applied to *original*. This - is useful when the plugin receives an isolated (CoW / deepcopy) - snapshot but accepted changes should be merged back into the - canonical pipeline payload. - - Returns: - An updated payload with only the allowed changes applied, or - ``None`` if the plugin made no effective (allowed) changes. - - Examples: - >>> from pydantic import BaseModel, ConfigDict - >>> class P(BaseModel): - ... model_config = ConfigDict(frozen=True) - ... name: str - ... secret: str - >>> orig = P(name="old", secret="s") - >>> mod = P(name="new", secret="hacked") - >>> policy = HookPayloadPolicy(writable_fields=frozenset({"name"})) - >>> result = apply_policy(orig, mod, policy) - >>> result.name - 'new' - >>> result.secret - 's' - """ - target = apply_to if apply_to is not None else original - updates: dict[str, Any] = {} - rejected: list[str] = [] - for field in type(modified).model_fields: - old_val = getattr(original, field, _SENTINEL) - new_val = getattr(modified, field, _SENTINEL) - if new_val is _SENTINEL: - continue - # Use model_dump() for BaseModel comparisons to ensure reliable - # equality across StructuredData / extra="allow" instances. - if isinstance(old_val, BaseModel) and isinstance(new_val, BaseModel): - if old_val.model_dump() == new_val.model_dump(): - continue - elif new_val == old_val: - continue - if field in policy.writable_fields: - updates[field] = new_val - else: - rejected.append(field) - if rejected: - logger.warning("Policy rejected modifications to non-writable fields: %s", rejected) - return target.model_copy(update=updates) if updates else None diff --git a/cpex/framework/hooks/prompts.py b/cpex/framework/hooks/prompts.py deleted file mode 100644 index c5c8cbaa..00000000 --- a/cpex/framework/hooks/prompts.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/prompts.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Fred Araujo - -Pydantic models for prompt plugins. -This module implements the pydantic models associated with -the base plugin layer including configurations, and contexts. -""" - -# Standard -from enum import Enum -from typing import Any, Optional - -# Third-Party -from pydantic import Field, field_validator - -# First-Party -from cpex.framework.models import PluginPayload, PluginResult -from cpex.framework.protocols import PromptResultLike # noqa: F401 # pylint: disable=unused-import -from cpex.framework.utils import coerce_nested - - -class PromptHookType(str, Enum): - """MCP Forge Gateway hook points. - - Attributes: - prompt_pre_fetch: The prompt pre hook. - prompt_post_fetch: The prompt post hook. - - Examples: - >>> PromptHookType.PROMPT_PRE_FETCH - - >>> PromptHookType.PROMPT_PRE_FETCH.value - 'prompt_pre_fetch' - >>> PromptHookType('prompt_post_fetch') - - >>> list(PromptHookType) - [, ] - """ - - PROMPT_PRE_FETCH = "prompt_pre_fetch" - PROMPT_POST_FETCH = "prompt_post_fetch" - - -class PromptPrehookPayload(PluginPayload): - """A prompt payload for a prompt prehook. - - Attributes: - prompt_id (str): The ID of the prompt template. - args (dic[str,str]): The prompt template arguments. - - Examples: - >>> payload = PromptPrehookPayload(prompt_id="123", args={"user": "alice"}) - >>> payload.prompt_id - '123' - >>> payload.args - {'user': 'alice'} - >>> payload2 = PromptPrehookPayload(prompt_id="empty") - >>> payload2.args - {} - >>> p = PromptPrehookPayload(prompt_id="123", args={"name": "Bob", "time": "morning"}) - >>> p.prompt_id - '123' - >>> p.args["name"] - 'Bob' - """ - - prompt_id: str - args: Optional[dict[str, str]] = Field(default_factory=dict) - - -class PromptPosthookPayload(PluginPayload): - """A prompt payload for a prompt posthook. - - Attributes: - prompt_id (str): The prompt ID. - result (Any): The prompt result (accepts any PromptResultLike-satisfying object). - - Examples: - >>> from types import SimpleNamespace - >>> result = SimpleNamespace(messages=[], description=None) - >>> payload = PromptPosthookPayload(prompt_id="123", result=result) - >>> payload.prompt_id - '123' - """ - - prompt_id: str - result: Any # Satisfies PromptResultLike protocol (messages, description attributes) - - @field_validator("result", mode="before") - @classmethod - def _coerce_result(cls, v: Any) -> Any: - """Convert nested dicts to objects with attribute access. - - When deserializing from JSON (external server flows), ``result`` - arrives as a plain dict. This validator converts it to a - :class:`~cpex.framework.utils.StructuredData` so - that plugin code like ``payload.result.messages[0].content.text`` - works regardless of the transport. - - Args: - v: The raw value for the ``result`` field. - - Returns: - The coerced value with attribute access, or the original value. - """ - if isinstance(v, dict): - return coerce_nested(v) - return v - - -PromptPrehookResult = PluginResult[PromptPrehookPayload] -PromptPosthookResult = PluginResult[PromptPosthookPayload] - - -def _register_prompt_hooks() -> None: - """Register prompt hooks in the global registry. - - This is called lazily to avoid circular import issues. - """ - # Import here to avoid circular dependency at module load time - # First-Party - from cpex.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel - - registry = get_hook_registry() - - # Only register if not already registered (idempotent) - if not registry.is_registered(PromptHookType.PROMPT_PRE_FETCH): - registry.register_hook(PromptHookType.PROMPT_PRE_FETCH, PromptPrehookPayload, PromptPrehookResult) - registry.register_hook(PromptHookType.PROMPT_POST_FETCH, PromptPosthookPayload, PromptPosthookResult) - - -_register_prompt_hooks() diff --git a/cpex/framework/hooks/registry.py b/cpex/framework/hooks/registry.py deleted file mode 100644 index 661e192c..00000000 --- a/cpex/framework/hooks/registry.py +++ /dev/null @@ -1,203 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/registry.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Hook Registry. -This module provides a global registry for mapping hook types to their -corresponding payload and result Pydantic models. This enables external -plugins to properly serialize/deserialize payloads without needing direct -access to the specific plugin implementations. -""" - -# Standard -from typing import Dict, Optional, Type, Union - -# First-Party -from cpex.framework.models import PluginPayload, PluginResult - - -class HookRegistry: - """Global registry for hook type metadata. - - This singleton registry maintains mappings between hook type names and their - associated Pydantic models for payloads and results. It enables dynamic - serialization/deserialization for external plugins. - - Examples: - >>> from cpex.framework import PluginPayload, PluginResult - >>> registry = HookRegistry() - >>> registry.register_hook("test_hook", PluginPayload, PluginResult) - >>> registry.get_payload_type("test_hook") - - >>> registry.get_result_type("test_hook") - - """ - - _instance: Optional["HookRegistry"] = None - _hook_payloads: Dict[str, Type[PluginPayload]] = {} - _hook_results: Dict[str, Type[PluginResult]] = {} - - def __new__(cls) -> "HookRegistry": - """Ensure singleton pattern for the registry. - - Returns: - The singleton HookRegistry instance. - """ - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def register_hook( - self, - hook_type: str, - payload_class: Type[PluginPayload], - result_class: Type[PluginResult], - ) -> None: - """Register a hook type with its payload and result classes. - - Args: - hook_type: The hook type identifier (e.g., "prompt_pre_fetch"). - payload_class: The Pydantic model class for the hook's payload. - result_class: The Pydantic model class for the hook's result. - - Examples: - >>> registry = HookRegistry() - >>> from cpex.framework import PluginPayload, PluginResult - >>> registry.register_hook("custom_hook", PluginPayload, PluginResult) - """ - self._hook_payloads[hook_type] = payload_class - self._hook_results[hook_type] = result_class - - def get_payload_type(self, hook_type: str) -> Optional[Type[PluginPayload]]: - """Get the payload class for a hook type. - - Args: - hook_type: The hook type identifier. - - Returns: - The Pydantic payload class, or None if not registered. - - Examples: - >>> registry = HookRegistry() - >>> registry.get_payload_type("unknown_hook") - """ - return self._hook_payloads.get(hook_type) - - def get_result_type(self, hook_type: str) -> Optional[Type[PluginResult]]: - """Get the result class for a hook type. - - Args: - hook_type: The hook type identifier. - - Returns: - The Pydantic result class, or None if not registered. - - Examples: - >>> registry = HookRegistry() - >>> registry.get_result_type("unknown_hook") - """ - return self._hook_results.get(hook_type) - - def json_to_payload(self, hook_type: str, payload: Union[str, dict]) -> PluginPayload: - """Convert JSON to the appropriate payload Pydantic model. - - Args: - hook_type: The hook type identifier. - payload: The payload as JSON string or dictionary. - - Returns: - The deserialized Pydantic payload object. - - Raises: - ValueError: If the hook type is not registered. - - Examples: - >>> registry = HookRegistry() - >>> from cpex.framework.hooks.prompts import PromptPrehookPayload, PromptPrehookResult - >>> registry.register_hook("test", PromptPrehookPayload, PromptPrehookResult) - >>> payload = registry.json_to_payload("test", {"prompt_id": "123"}) - """ - payload_class = self.get_payload_type(hook_type) - if not payload_class: - raise ValueError(f"No payload type registered for hook: {hook_type}") - - if isinstance(payload, str): - return payload_class.model_validate_json(payload) - return payload_class.model_validate(payload) - - def json_to_result(self, hook_type: str, result: Union[str, dict]) -> PluginResult: - """Convert JSON to the appropriate result Pydantic model. - - Args: - hook_type: The hook type identifier. - result: The result as JSON string or dictionary. - - Returns: - The deserialized Pydantic result object. - - Raises: - ValueError: If the hook type is not registered. - - Examples: - >>> registry = HookRegistry() - >>> from cpex.framework import PluginPayload, PluginResult - >>> registry.register_hook("test", PluginPayload, PluginResult) - >>> result = registry.json_to_result("test", '{"continue_processing": true}') - """ - result_class = self.get_result_type(hook_type) - if not result_class: - raise ValueError(f"No result type registered for hook: {hook_type}") - - if isinstance(result, str): - return result_class.model_validate_json(result) - return result_class.model_validate(result) - - def is_registered(self, hook_type: str) -> bool: - """Check if a hook type is registered. - - Args: - hook_type: The hook type identifier. - - Returns: - True if the hook is registered, False otherwise. - - Examples: - >>> registry = HookRegistry() - >>> registry.is_registered("unknown") - False - """ - return hook_type in self._hook_payloads and hook_type in self._hook_results - - def get_registered_hooks(self) -> list[str]: - """Get all registered hook types. - - Returns: - List of registered hook type identifiers. - - Examples: - >>> registry = HookRegistry() - >>> hooks = registry.get_registered_hooks() - >>> isinstance(hooks, list) - True - """ - return list(self._hook_payloads.keys()) - - -# Global singleton instance -_global_registry = HookRegistry() - - -def get_hook_registry() -> HookRegistry: - """Get the global hook registry instance. - - Returns: - The singleton HookRegistry instance. - - Examples: - >>> registry = get_hook_registry() - >>> isinstance(registry, HookRegistry) - True - """ - return _global_registry diff --git a/cpex/framework/hooks/resources.py b/cpex/framework/hooks/resources.py deleted file mode 100644 index 71639865..00000000 --- a/cpex/framework/hooks/resources.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/resources.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Pydantic models for resource hooks. -""" - -# Standard -from enum import Enum -from typing import Any, Optional - -# Third-Party -from pydantic import Field - -# First-Party -from cpex.framework.models import PluginPayload, PluginResult - - -class ResourceHookType(str, Enum): - """MCP Forge Gateway resource hook points. - - Attributes: - resource_pre_fetch: The resource pre fetch hook. - resource_post_fetch: The resource post fetch hook. - - Examples: - >>> ResourceHookType.RESOURCE_PRE_FETCH - - >>> ResourceHookType.RESOURCE_PRE_FETCH.value - 'resource_pre_fetch' - >>> ResourceHookType('resource_post_fetch') - - >>> list(ResourceHookType) - [, ] - """ - - RESOURCE_PRE_FETCH = "resource_pre_fetch" - RESOURCE_POST_FETCH = "resource_post_fetch" - - -class ResourcePreFetchPayload(PluginPayload): - """A resource payload for a resource pre-fetch hook. - - Attributes: - uri: The resource URI. - metadata: Optional metadata for the resource request. - - Examples: - >>> payload = ResourcePreFetchPayload(uri="file:///data.txt") - >>> payload.uri - 'file:///data.txt' - >>> payload2 = ResourcePreFetchPayload(uri="http://api/data", metadata={"Accept": "application/json"}) - >>> payload2.metadata - {'Accept': 'application/json'} - >>> p = ResourcePreFetchPayload(uri="file:///docs/readme.md", metadata={"version": "1.0"}) - >>> p.uri - 'file:///docs/readme.md' - >>> p.metadata["version"] - '1.0' - """ - - uri: str - metadata: Optional[dict[str, Any]] = Field(default_factory=dict) - - -class ResourcePostFetchPayload(PluginPayload): - """A resource payload for a resource post-fetch hook. - - Attributes: - uri: The resource URI. - content: The fetched resource content. - - Examples: - >>> import types - >>> content = types.SimpleNamespace(type="resource", id="res-1", uri="file:///data.txt", - ... text="Hello World") - >>> payload = ResourcePostFetchPayload(uri="file:///data.txt", content=content) - >>> payload.uri - 'file:///data.txt' - >>> payload.content.text - 'Hello World' - >>> resource_content = types.SimpleNamespace(type="resource", id="res-2", uri="test://resource", - ... text="Test data") - >>> p = ResourcePostFetchPayload(uri="test://resource", content=resource_content) - >>> p.uri - 'test://resource' - """ - - uri: str - content: Any - - -ResourcePreFetchResult = PluginResult[ResourcePreFetchPayload] -ResourcePostFetchResult = PluginResult[ResourcePostFetchPayload] - - -def _register_resource_hooks() -> None: - """Register resource hooks in the global registry. - - This is called lazily to avoid circular import issues. - """ - # Import here to avoid circular dependency at module load time - # First-Party - from cpex.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel - - registry = get_hook_registry() - - # Only register if not already registered (idempotent) - if not registry.is_registered(ResourceHookType.RESOURCE_PRE_FETCH): - registry.register_hook(ResourceHookType.RESOURCE_PRE_FETCH, ResourcePreFetchPayload, ResourcePreFetchResult) - registry.register_hook(ResourceHookType.RESOURCE_POST_FETCH, ResourcePostFetchPayload, ResourcePostFetchResult) - - -_register_resource_hooks() diff --git a/cpex/framework/hooks/tools.py b/cpex/framework/hooks/tools.py deleted file mode 100644 index 94aacc54..00000000 --- a/cpex/framework/hooks/tools.py +++ /dev/null @@ -1,134 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/hooks/tools.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Pydantic models for tool hooks. -""" - -# Standard -import warnings -from enum import Enum -from typing import Any, Optional - -# Third-Party -from pydantic import Field, field_validator - -# First-Party -from cpex.framework.hooks.http import HttpHeaderPayload -from cpex.framework.models import PluginPayload, PluginResult - - -class ToolHookType(str, Enum): - """MCP Forge Gateway hook points. - - Attributes: - tool_pre_invoke: The tool pre invoke hook. - tool_post_invoke: The tool post invoke hook. - - Examples: - >>> ToolHookType.TOOL_PRE_INVOKE - - >>> ToolHookType.TOOL_PRE_INVOKE.value - 'tool_pre_invoke' - >>> ToolHookType('tool_post_invoke') - - >>> list(ToolHookType) - [, ] - """ - - TOOL_PRE_INVOKE = "tool_pre_invoke" - TOOL_POST_INVOKE = "tool_post_invoke" - - -class ToolPreInvokePayload(PluginPayload): - """A tool payload for a tool pre-invoke hook. - - Args: - name: The tool name. - args: The tool arguments for invocation. - headers: The http pass through headers. - - Examples: - >>> payload = ToolPreInvokePayload(name="test_tool", args={"input": "data"}) - >>> payload.name - 'test_tool' - >>> payload.args - {'input': 'data'} - >>> payload2 = ToolPreInvokePayload(name="empty") - >>> payload2.args - {} - >>> p = ToolPreInvokePayload(name="calculator", args={"operation": "add", "a": 5, "b": 3}) - >>> p.name - 'calculator' - >>> p.args["operation"] - 'add' - - """ - - name: str - args: Optional[dict[str, Any]] = Field(default_factory=dict) - headers: Optional[HttpHeaderPayload] = None - - @field_validator("headers", mode="before") - @classmethod - def _warn_headers_deprecated(cls, v: object) -> object: - """Emit deprecation warning for headers field.""" - if v is not None: - warnings.warn( - "ToolPreInvokePayload.headers is deprecated; " - "use extensions.http.headers instead. " - "This field will be removed in a future release.", - DeprecationWarning, - stacklevel=4, - ) - return v - - -class ToolPostInvokePayload(PluginPayload): - """A tool payload for a tool post-invoke hook. - - Args: - name: The tool name. - result: The tool invocation result. - - Examples: - >>> payload = ToolPostInvokePayload(name="calculator", result={"result": 8, "status": "success"}) - >>> payload.name - 'calculator' - >>> payload.result - {'result': 8, 'status': 'success'} - >>> p = ToolPostInvokePayload(name="analyzer", result={"confidence": 0.95, "sentiment": "positive"}) - >>> p.name - 'analyzer' - >>> p.result["confidence"] - 0.95 - """ - - name: str - result: Any - - -ToolPreInvokeResult = PluginResult[ToolPreInvokePayload] -ToolPostInvokeResult = PluginResult[ToolPostInvokePayload] - - -def _register_tool_hooks() -> None: - """Register Tool hooks in the global registry. - - This is called lazily to avoid circular import issues. - """ - # Import here to avoid circular dependency at module load time - # First-Party - from cpex.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel - - registry = get_hook_registry() - - # Only register if not already registered (idempotent) - if not registry.is_registered(ToolHookType.TOOL_PRE_INVOKE): - registry.register_hook(ToolHookType.TOOL_PRE_INVOKE, ToolPreInvokePayload, ToolPreInvokeResult) - registry.register_hook(ToolHookType.TOOL_POST_INVOKE, ToolPostInvokePayload, ToolPostInvokeResult) - - -_register_tool_hooks() diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py deleted file mode 100644 index c61a3981..00000000 --- a/cpex/framework/isolated/client.py +++ /dev/null @@ -1,345 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/isolated/client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Isolated plugin client -Module that contains plugin client code to serve venv isolated plugins. -""" - -import asyncio -import functools -import hashlib -import json -import logging -import os -import shutil -import sys -import venv -from pathlib import Path - -from typing_extensions import Any, Optional - -from cpex.framework.base import Plugin -from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME -from cpex.framework.errors import PluginError, convert_exception_to_error -from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.isolated.venv_comm import VenvProcessCommunicator -from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult -from cpex.framework.utils import find_package_path - -logger = logging.getLogger(__name__) - - -class IsolatedVenvPlugin(Plugin): - """IsolatedVenvPlugin class.""" - - def __init__(self, config: PluginConfig, plugin_dirs) -> None: - """Initialize the plugin's venv environment.""" - super().__init__(config) - self.implementation = "Python" - self.comm = None - self.plugin_dirs = plugin_dirs - # use the first plugin dir specified in the plugin configuration file. - path = Path(self.plugin_dirs[0]).resolve() - class_root = self.config.config.get("class_name").split(".")[0] - cache_root: Path = path / class_root - self.plugin_path: Path = cache_root - if not cache_root.exists(): - cache_root.mkdir(parents=True, exist_ok=True) - self.cache_dir: Path = cache_root / ".cpex" / "venv_cache" - self.cache_dir.mkdir(parents=True, exist_ok=True) - - def _compute_requirements_hash(self, requirements_file: str) -> str: - """Compute SHA256 hash of requirements file content. - - Args: - requirements_file: Path to the requirements file - - Returns: - Hexadecimal hash string - """ - hasher = hashlib.sha256() - req_path = Path(requirements_file) - - if req_path.exists(): - with open(req_path, "rb") as f: - hasher.update(f.read()) - else: - # If no requirements file, use empty hash - hasher.update(b"") - - return hasher.hexdigest() - - def _get_cache_metadata_path(self, venv_path: str) -> Path: - """Get the path to the cache metadata file. - - Args: - venv_path: Path to the virtual environment - - Returns: - Path to the metadata file - """ - venv_name = Path(venv_path).name - return self.cache_dir / f"{venv_name}_metadata.json" - - def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: - """Check if cached venv is valid by comparing requirements hash. - - Args: - venv_path: Path to the virtual environment - requirements_file: Path to the requirements file - - Returns: - True if cache is valid, False otherwise - """ - venv_path_obj = Path(venv_path) - metadata_path = self._get_cache_metadata_path(venv_path) - - # Check if venv directory exists - if not venv_path_obj.exists(): - logger.debug("Venv path does not exist: %s", venv_path) - return False - - # Check if metadata file exists - if not metadata_path.exists(): - logger.debug("Metadata file does not exist: %s", metadata_path) - return False - - try: - # Load metadata - with open(metadata_path, "r", encoding="utf8") as f: - metadata = json.load(f) - - # Compute current requirements hash - current_hash = self._compute_requirements_hash(requirements_file) - - # Compare hashes - cached_hash = metadata.get("requirements_hash") - if cached_hash != current_hash: - logger.info("Requirements changed. Cached hash: %s, Current hash: %s", cached_hash, current_hash) - return False - - logger.info("Valid venv cache found for %s", venv_path) - return True - - except (json.JSONDecodeError, KeyError) as e: - logger.warning("Error reading cache metadata: %s", str(e)) - return False - - def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: - """Save cache metadata for the venv. - - Args: - venv_path: Path to the virtual environment - requirements_file: Path to the requirements file - """ - metadata_path = self._get_cache_metadata_path(venv_path) - requirements_hash = self._compute_requirements_hash(requirements_file) - - metadata = { - "venv_path": str(Path(venv_path).resolve()), - "requirements_file": str(Path(requirements_file).resolve()) if Path(requirements_file).exists() else None, - "requirements_hash": requirements_hash, - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - } - - with open(metadata_path, "w", encoding="utf8") as f: - json.dump(metadata, f, indent=2) - - logger.info("Saved cache metadata to %s", metadata_path) - - async def create_venv( - self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True - ) -> bool: - """Create a new venv environment with caching support. - - Args: - venv_path: Path where the virtual environment should be created - requirements_file: Path to requirements file for cache validation - use_cache: Whether to use cached venv if available - """ - venv_path_obj = Path(venv_path) - - # Check if we can use cached venv - if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): - logger.info("✓ Using cached virtual environment at: %s", venv_path_obj.resolve()) - return False - - # If cache is invalid or not using cache, remove existing venv - if venv_path_obj.exists(): - logger.info("Removing existing venv at %s", venv_path) - shutil.rmtree(venv_path_obj) - - # Check Python version - python_version = sys.version_info - logger.info(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") - - # Create the EnvBuilder with common options - builder = venv.EnvBuilder( - system_site_packages=False, # Don't include system site-packages - clear=False, # Don't clear existing venv if it exists - symlinks=True, # Use symlinks (recommended on Unix-like systems) - upgrade=False, # Don't upgrade existing venv - with_pip=True, # Install pip in the venv - prompt=None, # Use default prompt (directory name) - ) - - # Create the virtual environment - logger.info(f"\nCreating virtual environment at: {venv_path_obj.resolve()}") - try: - builder.create(venv_path) - logger.info("✓ Virtual environment created successfully!") - logger.info("\nTo activate the virtual environment:") - logger.info(f" source {venv_path}/bin/activate # On Unix/macOS") - logger.info(f" {venv_path}\\Scripts\\activate # On Windows") - return True - except Exception as e: - logger.error(f"✗ Error creating virtual environment: {e}") - raise - - # Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin() - # The plugins/framework/manager.py class (PluginManager) loads and registers the plugin - async def initialize(self) -> None: - """Initialize the plugin's venv environment with caching support.""" - # ensure the config is validated - if not os.path.exists(self.plugin_path): - raise FileNotFoundError(f"plugin path not found: {self.plugin_path}") - - venv_path = self.plugin_path / ".venv" - - # Prevent directory traversal: ensure requirements_file stays within plugin_path - requirements_file_input = self.config.config["requirements_file"] - - # Handle both relative and absolute paths - if isinstance(requirements_file_input, Path): - requirements_file = requirements_file_input - else: - requirements_file = Path(requirements_file_input) - - # Try to find the package location where plugin-manifest.yaml resides - # Fall back to self.plugin_path if package is not installed (e.g., in tests) - try: - package_path = find_package_path(self.config.name) - logger.debug("Found installed package %s at %s", self.config.name, package_path) - except RuntimeError: - # Package not installed (e.g., in test environment), use plugin_path - package_path = self.plugin_path - logger.debug("Package %s not installed, using plugin_path: %s", self.config.name, package_path) - - requirements_file = package_path / requirements_file_input - - # Create venv with caching support - new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) - - self.comm = VenvProcessCommunicator(venv_path) - - # Only install requirements if venv was newly created or cache was invalid - # Check if we need to install requirements - if new_venv: - logger.info("Installing requirements in venv") - self.comm.install_requirements(requirements_file) - # Save metadata after successful installation - self._save_cache_metadata(venv_path, requirements_file) - else: - logger.info("Using cached venv, skipping requirements installation") - - async def cleanup(self) -> None: - """Cleanup resources, including stopping the worker process.""" - if self.comm: - logger.info("Stopping worker process for plugin '%s'", self.name) - self.comm.stop_worker() - self.comm = None - - def _validate_hook_invocation(self, hook_type: str) -> type[PluginResult]: - """Validate hook type and communication channel. - - Args: - hook_type: The hook type to validate - - Returns: - The result type for the hook - - Raises: - PluginError: If validation fails - """ - registry = get_hook_registry() - result_type = registry.get_result_type(hook_type) - if not result_type: - raise PluginError( - error=PluginErrorModel( - message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name - ) - ) - - if not self.comm: - raise PluginError(error=PluginErrorModel(message="Plugin comm not initialized", plugin_name=self.name)) - - return result_type - - def _build_hook_task(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> dict[str, Any]: - """Build task dictionary for hook invocation. - - Args: - hook_type: The hook type to invoke - payload: The payload to send - context: The context to send - - Returns: - Task dictionary ready for transmission - """ - # Cache config lookups - class_name = self.config.config["class_name"] - safe_config = self.config.get_safe_config() - - # Serialize payload and context to ensure they are JSON-serializable - serialized_payload = payload.model_dump(mode="json") if payload is not None else None - serialized_context = context.model_dump(mode="json") if context is not None else None - - return { - "task_type": "load_and_run_hook", - "plugin_dirs": self.plugin_dirs, - "class_name": class_name, - "config": safe_config, - HOOK_TYPE: hook_type, - PLUGIN_NAME: self.name, - PAYLOAD: serialized_payload, - CONTEXT: serialized_context, - } - - async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: - """Invoke a plugin in the context of the active venv (self.comm)""" - try: - # Validate and get result type - self._validate_hook_invocation(hook_type) - - # Build and send task - task_data = self._build_hook_task(hook_type, payload, context) - loop = asyncio.get_event_loop() - result_dict: dict[str, Any] = await loop.run_in_executor( - None, - functools.partial( - self.comm.send_task, - script_path="cpex/framework/isolated/worker.py", - task_data=task_data, - max_content_size=self.config.max_content_size, - ), - ) - # Convert response to typed result - registry = get_hook_registry() - return registry.json_to_result(hook_type, result_dict) - - except PluginError: - logger.exception("Plugin error invoking hook '%s' for plugin '%s'", hook_type, self.name) - raise - except Exception as e: - logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name) - raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e - - def remove_venv(self): - """ - Remove the virtual environment associated with the plugin. - """ - shutil.rmtree(self.plugin_path.joinpath(".cpex")) - shutil.rmtree(self.plugin_path.joinpath(".venv")) diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py deleted file mode 100644 index c7325236..00000000 --- a/cpex/framework/isolated/venv_comm.py +++ /dev/null @@ -1,279 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Location: ./cpex/framework/isolated/venv_comm.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo, Ted Habeck -""" - -import logging -import os -import subprocess -import sys -import threading -import uuid -from pathlib import Path -from queue import Empty, Queue -from typing import Any, Optional - -import orjson - -logger = logging.getLogger(__name__) - - -class VenvProcessCommunicator: - """Handles communication with a long-running child process in a different virtual environment.""" - - def __init__(self, venv_path: str) -> None: - """ - Initialize communicator with target virtual environment. - - Args: - venv_path (str): Path to the virtual environment directory - """ - self.venv_path = Path(venv_path) - self.python_executable = self._get_python_executable() - self.process: Optional[subprocess.Popen] = None - self.reader_thread: Optional[threading.Thread] = None - self.stderr_thread: Optional[threading.Thread] = None - self.response_queues: dict[str, Queue] = {} - self.lock = threading.Lock() - self.running = False - logger.info("cwd: %s", os.getcwd()) - - def _get_python_executable(self): - """Get the Python executable path for the target venv.""" - if sys.platform == "win32": - python_exe = self.venv_path / "Scripts" / "python.exe" - else: - python_exe = self.venv_path / "bin" / "python" - - if not python_exe.exists(): - raise FileNotFoundError(f"Python executable not found at {python_exe}") - - return str(python_exe) - - def upgrade_pip(self) -> None: - """Upgrade pip in the target venv.""" - try: - subprocess.check_call([self.python_executable, "-m", "pip", "install", "--upgrade", "pip"]) - except Exception as e: - raise RuntimeError("Failed to upgrade pip") from e - - def install_requirements(self, requirements_file: str) -> None: - """ - Install Python requirements from a file in the target venv. - Args: - requirements_file (str): Path to the requirements file. - """ - requirements_path = Path(requirements_file) - if requirements_path.exists(): - try: - self.upgrade_pip() - subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file]) - except Exception as e: - raise RuntimeError(f"Failed to install requirements from {requirements_file}") from e - - def start_worker(self, script_path: str) -> None: - """ - Start the long-running worker process. - - Args: - script_path (str): Path to the worker script - """ - if self.running: - logger.warning("Worker process already running") - return - - try: - # Start child process - self.process = subprocess.Popen( - [self.python_executable, script_path], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, # Line buffered - cwd=os.getcwd(), - env={"PLUGINS_CONFIG_FILE": os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml")}, - ) - - self.running = True - - # Start reader thread to handle responses - self.reader_thread = threading.Thread(target=self._read_responses, daemon=True) - self.reader_thread.start() - - # Start stderr reader thread to capture errors - self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) - self.stderr_thread.start() - - logger.info("Worker process started with PID: %s", self.process.pid) - - except Exception as e: - self.running = False - raise RuntimeError(f"Failed to start worker process: {e}") from e - - def _read_stderr(self) -> None: - """Background thread to read and log stderr from worker process.""" - if not self.process or not self.process.stderr: - return - - while self.running and self.process and self.process.stderr: - try: - line = self.process.stderr.readline() - if not line: - break - # Log stderr output from worker - logger.debug("Worker stderr: %s", line.strip()) - except Exception as e: - logger.error("Error reading stderr: %s", e) - break - - def _read_responses(self) -> None: - """Background thread to read responses from worker process.""" - while self.running and self.process and self.process.stdout: - try: - line = self.process.stdout.readline() - if not line: - # Process has terminated - logger.warning("Worker process stdout closed") - break - - line = line.strip() - if not line: - # Empty line, skip - continue - - try: - response = orjson.loads(line) - request_id = response.get("request_id") - - if request_id: - with self.lock: - if request_id in self.response_queues: - self.response_queues[request_id].put(response) - logger.debug("Response queued for request_id: %s", request_id) - else: - logger.warning("Received response for unknown request_id: %s", request_id) - else: - logger.warning("Received response without request_id: %s", line[:100]) - - except orjson.JSONDecodeError as e: - logger.error("Failed to decode response: %s, line: %s", e, line[:200]) - - except Exception as e: - logger.exception("Error reading response: %s", e) - break - - self.running = False - logger.info("Response reader thread terminated") - - def send_task( - self, script_path: str, task_data: Any, timeout: float = 30.0, max_content_size: int = 10000000 - ) -> Any: - """ - Send a task to the long-running worker process and get response. - - Args: - script_path (str): Path to the child script (used for worker initialization) - task_data (dict): Data to send to child process - timeout (float): Timeout in seconds for waiting for response - - Returns: - dict: Response from child process - """ - # Start worker if not running - if not self.running: - self.start_worker(script_path) - - # Generate unique request ID - request_id = str(uuid.uuid4()) - task_data["request_id"] = request_id - - # Create response queue for this request - response_queue: Queue = Queue() - with self.lock: - self.response_queues[request_id] = response_queue - - try: - # Send task to worker - input_json = orjson.dumps(task_data).decode() - if len(input_json) > max_content_size: - # remove the request_id from the response queue and raise - self.response_queues.pop(request_id) - raise RuntimeError(f"task_data exceeds max_content_size. {len(input_json)}") - if self.process and self.process.stdin: - self.process.stdin.write(input_json + "\n") - self.process.stdin.flush() - else: - raise RuntimeError("Worker process stdin not available") - - # Wait for response - try: - response = response_queue.get(timeout=timeout) - - # Check for errors in response - if response.get("status") == "error": - raise RuntimeError(f"Worker process error: {response.get('message')}") - - # Remove request_id from response before returning - response.pop("request_id", None) - return response - - except Empty: - raise RuntimeError(f"Worker process timed out after {timeout} seconds") - - finally: - # Clean up response queue - with self.lock: - self.response_queues.pop(request_id, None) - - def stop_worker(self) -> None: - """Stop the long-running worker process.""" - if not self.running: - return - - self.running = False - - try: - if self.process: - # Send shutdown signal - if self.process.stdin: - try: - shutdown_task = {"task_type": "shutdown", "request_id": "shutdown"} - self.process.stdin.write(orjson.dumps(shutdown_task).decode() + "\n") - self.process.stdin.flush() - except Exception as e: - logger.warning("Failed to send shutdown signal: %s", e) - - # Wait for process to terminate gracefully - try: - self.process.wait(timeout=5.0) - except subprocess.TimeoutExpired: - logger.warning("Worker process did not terminate gracefully, killing it") - self.process.kill() - self.process.wait() - - logger.info("Worker process stopped") - - except Exception as e: - logger.error("Error stopping worker process: %s", e) - - finally: - self.process = None - if self.reader_thread and self.reader_thread.is_alive(): - self.reader_thread.join(timeout=2.0) - self.reader_thread = None - if self.stderr_thread and self.stderr_thread.is_alive(): - self.stderr_thread.join(timeout=2.0) - self.stderr_thread = None - - def is_alive(self) -> bool: - """Check if the worker process is alive and running.""" - return self.running and self.process is not None and self.process.poll() is None - - def __del__(self): - """Cleanup when object is destroyed.""" - if hasattr(self, "running"): - self.stop_worker() diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py deleted file mode 100644 index 74deee05..00000000 --- a/cpex/framework/isolated/worker.py +++ /dev/null @@ -1,241 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/isolated/worker.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck, Fred Araujo - -Isolated plugin server -Module that contains plugin server code to invoke hooks in native plugins. -""" - -import asyncio -import hashlib -import importlib.metadata -import json -import logging -import platform -import sys -from pathlib import Path -from types import ModuleType -from typing import List, Type, cast - -from cpex.framework.base import HookRef, Plugin, PluginRef -from cpex.framework.constants import HOOK_TYPE -from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS -from cpex.framework.manager import PluginExecutor -from cpex.framework.models import PluginConfig, PluginContext -from cpex.framework.utils import import_module, parse_class_name - -logger = logging.getLogger(__name__) - - -class TaskProcessor: - """ - A Caching task processor that only reloads the plugin if the config has changed. - """ - - config_hash: str - module_path_hash: str - hook_ref: HookRef - executor: PluginExecutor - plugin_config: PluginConfig | None = None - - def __init__(self) -> None: - """Initialize defaults.""" - hasher = hashlib.sha256() - hasher.update(b"") - self.config_hash = hasher.hexdigest() - self.module_path_hash = self.config_hash - - def compute_hash(self, json_config_or_module_path: str): - """Compute the hash of the supplied string""" - hasher = hashlib.sha256() - hasher.update(json_config_or_module_path.encode()) - return hasher.hexdigest() - - def initialize( - self, - hook_ref: HookRef, - executor: PluginExecutor, - json_config: str, - module_path: str, - plugin_config: PluginConfig, - ): - """Assign locals, and compute hashes.""" - self.hook_ref = hook_ref - self.executor = executor - self.config_hash = self.compute_hash(json_config_or_module_path=json_config) - self.module_path_hash = self.compute_hash(json_config_or_module_path=module_path) - self.plugin_config = plugin_config - - -def get_environment_info(): - """Get information about current Python environment.""" - return { - "python_version": sys.version, - "python_executable": sys.executable, - "platform": platform.platform(), - "installed_packages": [str(d) for d in importlib.metadata.entry_points()][:10], # First 10 packages - } - - -async def process_task(task_data, tp: TaskProcessor): - """Process the task received from parent.""" - task_type = task_data.get("task_type") - - if task_type == "info": - return { - "status": "success", - "environment": get_environment_info(), - "message": "Environment info retrieved successfully", - } - # This is essentially emulating the plugin loader's load and instantiate plugin - if task_type == "load_and_run_hook": - # relative path from project root. - json_config = task_data.get("config") - config_raw = json.loads(json_config) - module_paths: List[str] = task_data.get("plugin_dirs") - resolved_paths: List[str] = [] - for module_path in module_paths: - path = Path(module_path).resolve() - resolved_module_path = str(path) - if path.exists(): - resolved_paths.append(resolved_module_path) - if resolved_module_path not in sys.path: - if resolved_module_path.startswith(tuple(ALLOWED_PLUGIN_DIRS)): - sys.path.append(resolved_module_path) - else: - raise RuntimeError(f"plugin module_path '{resolved_module_path}' not in allowed plugin dirs.") - else: - raise RuntimeError(f"plugin module_path '{resolved_module_path}' does not exist.") - - if tp.config_hash != tp.compute_hash(json_config): - # pull the resolved plugin path and only add the module path if it has the same root - config: PluginConfig = PluginConfig(**config_raw) - hook_type = task_data.get(HOOK_TYPE) - cls_name: str = task_data.get("class_name") - mod_name, n_cls_name = parse_class_name(cls_name) - module: ModuleType = import_module(mod_name) - # cool, we found the module, and verified it implemented the hook type. - class_ = getattr(module, n_cls_name) - plugin_type = cast(Type[Plugin], class_) - plugin = plugin_type(config) - await plugin.initialize() - # now invoke the hook - plugin_ref = PluginRef(plugin) - hook_ref = HookRef(hook_type, plugin_ref) - executor = PluginExecutor(None, 30) - tp.initialize( - hook_ref=hook_ref, - executor=executor, - json_config=json_config, - module_path=json.dumps(resolved_paths), - plugin_config=config, - ) - # retrieve the context - context = task_data.get("context") - plugin_context = PluginContext( - state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") - ) - result = await tp.executor.execute_plugin( - hook_ref=tp.hook_ref, - payload=task_data.get("payload"), - local_context=plugin_context, - violations_as_exceptions=False, - ) - return result - return { - "status": "error", - "message": "task type not supported.", - "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", - } - - -async def main(): - """Main function - continuously read from stdin, process tasks, write to stdout.""" - logger.info("Worker process started, waiting for tasks...") - - try: - # Cache the plugin so that it only has to be initialized once - tp = TaskProcessor() - # Continuously read and process tasks - while True: - try: - # Read one line at a time - if tp.plugin_config and "max_content_size" in tp.plugin_config: - line = sys.stdin.readline(limit=int(tp.plugin_config.max_content_size)) - else: - # on the first read, the plugin_config has not yet been initialized so just read. - line = sys.stdin.readline() - # Check for EOF - if not line: - logger.info("EOF received, shutting down worker") - break - - # Parse the task - task_data = json.loads(line.strip()) - request_id = task_data.get("request_id", "unknown") - - # Check for shutdown signal - if task_data.get("task_type") == "shutdown": - logger.info("Shutdown signal received") - response = {"status": "success", "message": "Shutting down", "request_id": request_id} - print(json.dumps(response), flush=True) - break - - # Process the task - response = await process_task(task_data, tp) - - # Serialize response - if response: - serializable_response = response.model_dump(mode="json") - else: # none case should be a failure rather than success. - serializable_response = {"status": "success"} - - # Add request_id to response - serializable_response["request_id"] = request_id - - serialized_response = json.dumps(serializable_response) - # Send response back to parent (one line per response) - if tp.plugin_config: - # workaround until cpex is updated beyond dev11 - # cpex is a dependency of the plugin and as such it's PluginConfig does not contain the max_content_size yet. - if "max_content_size" in tp.plugin_config: - if len(serialized_response) > tp.plugin_config.max_content_size: - logger.error("Serialized response exceeds max content size") - error_response = { - "status": "error", - "message": "Serialized response exceeds max content size", - "request_id": request_id, - } - serialized_response = json.dumps(error_response) - print(serialized_response, flush=True) - - except json.JSONDecodeError as e: - error_response = { - "status": "error", - "message": f"Invalid JSON input: {str(e)}", - "request_id": "unknown", - } - print(json.dumps(error_response), flush=True) - - except Exception as e: - logger.error("Error processing task: %s", str(e)) - error_response = { - "status": "error", - "message": f"Unexpected error: {str(e)}", - "request_id": "unknown", - } - print(json.dumps(error_response), flush=True) - - except KeyboardInterrupt: - logger.info("Worker interrupted") - except Exception: - logger.exception("Fatal error in worker main loop") - finally: - logger.info("Worker process shutting down") - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - asyncio.run(main()) diff --git a/cpex/framework/loader/__init__.py b/cpex/framework/loader/__init__.py deleted file mode 100644 index eb155380..00000000 --- a/cpex/framework/loader/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/loader/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -External plugins package. -Exposes external plugin components: -- server -- external plugin client -""" diff --git a/cpex/framework/loader/config.py b/cpex/framework/loader/config.py deleted file mode 100644 index a7b1eb70..00000000 --- a/cpex/framework/loader/config.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/loader/config.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Mihai Criveti - -Configuration loader implementation. -This module loads configurations for plugins. -""" - -# Standard -import os - -# Third-Party -import jinja2 -import yaml -from jinja2.sandbox import SandboxedEnvironment - -# First-Party -from cpex.framework.models import Config - - -class ConfigLoader: - """A configuration loader. - - Examples: - >>> import tempfile - >>> import os - >>> # Create a temporary config file - >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - ... _ = f.write(\"\"\" - ... plugin_dirs: ['/path/to/plugins'] - ... \"\"\") - ... temp_path = f.name - >>> try: - ... config = ConfigLoader.load_config(temp_path, use_jinja=False) - ... config.plugin_dirs - ... finally: - ... os.unlink(temp_path) - ['/path/to/plugins'] - """ - - @staticmethod - def load_config(config: str, use_jinja: bool = True) -> Config: - """Load the plugin configuration from a file path. - - Args: - config: the configuration path. - use_jinja: use jinja to replace env variables if true. - - Returns: - The plugin configuration object. - - Examples: - >>> import tempfile - >>> import os - >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - ... _ = f.write(\"\"\" - ... plugin_dirs: [] - ... \"\"\") - ... temp_path = f.name - >>> try: - ... cfg = ConfigLoader.load_config(temp_path, use_jinja=False) - ... cfg.plugin_dirs - ... finally: - ... os.unlink(temp_path) - [] - """ - try: - with open(os.path.normpath(config), "r", encoding="utf-8") as file: - template = file.read() - if use_jinja: - jinja_env = SandboxedEnvironment(loader=jinja2.BaseLoader(), autoescape=True) - rendered_template = jinja_env.from_string(template).render(env=os.environ) - else: - rendered_template = template - config_data = yaml.safe_load(rendered_template) or {} - return Config(**config_data) - except FileNotFoundError: - # Graceful fallback for tests and minimal environments without plugin config - return Config(plugins=[], plugin_dirs=[]) - - -class ConfigSaver: - """ - A configuration saver - """ - - @staticmethod - def save_config(config: Config, config_path: str) -> None: - """ - Save the supplied configuration data to the filesystem - """ - try: - updated_content = yaml.safe_dump(config.model_dump(mode="json"), default_flow_style=False) - with open(os.path.normpath(config_path), "w", encoding="utf-8") as file: - file.write(updated_content) - file.flush() - except OSError as ose: - raise RuntimeError(f"Error saving PluginConfig to {config_path}") from ose diff --git a/cpex/framework/loader/plugin.py b/cpex/framework/loader/plugin.py deleted file mode 100644 index 7de57794..00000000 --- a/cpex/framework/loader/plugin.py +++ /dev/null @@ -1,193 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/loader/plugin.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Mihai Criveti - -Plugin loader implementation. -This module implements the plugin loader. -""" - -# Standard -import logging -import os -import sys -from pathlib import Path -from typing import Type, cast - -# First-Party -from cpex.framework.base import Plugin -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE, ISOLATED_VENV_PLUGIN_TYPE -from cpex.framework.external.mcp.client import ExternalPlugin -from cpex.framework.models import PluginConfig -from cpex.framework.utils import import_module, parse_class_name - -# Use standard logging to avoid circular imports (plugins -> services -> plugins) -logger = logging.getLogger(__name__) - -# Allowed plugin dirs to use as plugin search paths -ALLOWED_PLUGIN_DIRS = { - os.path.abspath("/var/lib/cpex/plugins"), - os.path.abspath("/private/var/lib/cpex/plugins"), - os.getcwd(), -} - - -class PluginLoader: - """A plugin loader object for loading and instantiating plugins. - - Examples: - >>> loader = PluginLoader() - >>> isinstance(loader._plugin_types, dict) - True - >>> len(loader._plugin_types) - 0 - """ - - def __init__(self) -> None: - """Initialize the plugin loader. - - Examples: - >>> loader = PluginLoader() - >>> loader._plugin_types - {} - """ - self._plugin_types: dict[str, Type[Plugin]] = {} - self.plugin_dirs: list[str] = [] - - def __get_plugin_type(self, kind: str) -> Type[Plugin]: - """Import a plugin type from a python module. - - Args: - kind: The fully-qualified type of the plugin to be registered. - - Raises: - Exception: if unable to import a module. - - Returns: - A plugin type. - """ - try: - mod_name, cls_name = parse_class_name(kind) - module = import_module(mod_name) - class_ = getattr(module, cls_name) - return cast(Type[Plugin], class_) - except Exception: - logger.exception("Unable to import plugin type '%s'", kind) - raise - - def __register_plugin_type(self, kind: str) -> None: - """Register a plugin type. - - Args: - kind: The fully-qualified type of the plugin to be registered. - """ - if kind not in self._plugin_types: - plugin_type: Type[Plugin] - if kind == EXTERNAL_PLUGIN_TYPE: - plugin_type = ExternalPlugin - else: - plugin_type = self.__get_plugin_type(kind) - self._plugin_types[kind] = plugin_type - - async def load_and_instantiate_plugin(self, config: PluginConfig) -> Plugin | None: - """Load and instantiate a plugin, given a configuration. - - The plugin receives a defensive copy of the config so it cannot - modify the authoritative config retained by the Manager/PluginRef. - - For external plugins, the transport type is determined by the presence - of 'mcp', 'grpc', or 'unix_socket' configuration: - - If 'grpc' is set, uses GrpcExternalPlugin for gRPC transport - - If 'mcp' is set, uses ExternalPlugin for MCP transport - - If 'unix_socket' is set, uses UnixSocketExternalPlugin for raw Unix socket transport - - Args: - config: A plugin configuration. - - Returns: - A plugin instance. - - Raises: - ValueError: If an external plugin has no transport configured. - """ - # Defensive copy — the plugin never sees the authoritative config - plugin_config = config.model_copy() - - # Handle external plugins with transport selection - if config.kind == EXTERNAL_PLUGIN_TYPE: - plugin: Plugin - if config.grpc: - # Use gRPC transport - # Import here to avoid circular dependency and to make grpc optional - # First-Party - from cpex.framework.external.grpc.client import ( - GrpcExternalPlugin, - ) # pylint: disable=import-outside-toplevel - - plugin = GrpcExternalPlugin(plugin_config) - logger.info("Loading external plugin '%s' with gRPC transport", config.name) - elif config.unix_socket: - # Use raw Unix socket transport (high-performance local IPC) - # First-Party - from cpex.framework.external.unix.client import ( - UnixSocketExternalPlugin, - ) # pylint: disable=import-outside-toplevel - - plugin = UnixSocketExternalPlugin(plugin_config) - logger.info("Loading external plugin '%s' with Unix socket transport", config.name) - elif config.mcp: - # Use MCP transport - plugin = ExternalPlugin(plugin_config) - logger.info("Loading external plugin '%s' with MCP transport", config.name) - else: - # Defensive fallback: PluginConfig validation should prevent this path. - raise ValueError( - f"External plugin '{config.name}' must have 'mcp', 'grpc', or 'unix_socket' configuration" - ) # pragma: no cover - - await plugin.initialize() - return plugin - - if config.kind == ISOLATED_VENV_PLUGIN_TYPE: - from cpex.framework.isolated.client import IsolatedVenvPlugin # pylint: disable=import-outside-toplevel - - plugin: Plugin = IsolatedVenvPlugin(config, plugin_dirs=self.plugin_dirs.copy()) - await plugin.initialize() - return plugin - - # Handle other plugin types - if config.kind not in self._plugin_types: - self.__register_plugin_type(config.kind) - plugin_type = self._plugin_types[config.kind] - if plugin_type: - plugin = plugin_type(plugin_config) - await plugin.initialize() - return plugin - return None - - def append_to_search_path(self, plugin_dirs: list[str]) -> None: - """Safe append plugin dir paths to search path. - - Args: - plugin_dirs: paths to append to search path - """ - for plugin_dir in plugin_dirs: - resolved = str(Path(plugin_dir).resolve()) - if resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)): - self.plugin_dirs.append(plugin_dir) - if resolved not in sys.path: - sys.path.append(resolved) - - async def shutdown(self) -> None: - """Shutdown and cleanup plugin loader. - - Examples: - >>> import asyncio - >>> loader = PluginLoader() - >>> asyncio.run(loader.shutdown()) - >>> loader._plugin_types - {} - """ - if self._plugin_types: - self._plugin_types.clear() diff --git a/cpex/framework/manager.py b/cpex/framework/manager.py deleted file mode 100644 index d67b1a9c..00000000 --- a/cpex/framework/manager.py +++ /dev/null @@ -1,1708 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/manager.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Mihai Criveti, Fred Araujo - -Plugin manager. -Module that manages and calls plugins at hookpoints throughout the gateway. - -This module provides the core plugin management functionality including: -- Plugin lifecycle management (initialization, execution, shutdown) -- Timeout protection for plugin execution -- Context management with automatic cleanup -- Priority-based plugin ordering -- Conditional plugin execution based on prompts/servers/tenants - -Examples: - >>> # Initialize plugin manager with configuration - >>> manager = PluginManager("plugins/config.yaml") - >>> # await manager.initialize() # Called in async context - - >>> # Create test payload and context - >>> from cpex.framework.models import GlobalContext - >>> from cpex.framework.hooks.prompts import PromptPrehookPayload - >>> payload = PromptPrehookPayload(prompt_id="123", name="test", args={"user": "input"}) - >>> context = GlobalContext(request_id="123") - >>> # result, contexts = await manager.prompt_pre_fetch(payload, context) # Called in async context -""" - -# Standard -import asyncio -import logging -import threading -from dataclasses import dataclass -from typing import Any, Literal, Optional, Union - -# Third-Party -from pydantic import BaseModel, RootModel - -# First-Party -from cpex.framework.base import HookRef, Plugin -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE -from cpex.framework.errors import PluginError, PluginViolationError, convert_exception_to_error -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.tiers import filter_extensions -from cpex.framework.hooks.policies import DefaultHookPolicy, HookPayloadPolicy, apply_policy -from cpex.framework.loader.config import ConfigLoader -from cpex.framework.loader.plugin import PluginLoader -from cpex.framework.memory import _safe_deepcopy, copyonwrite, wrap_payload_for_isolation -from cpex.framework.models import ( - Config, - GlobalContext, - OnError, - PluginContext, - PluginContextTable, - PluginErrorModel, - PluginMode, - PluginPayload, - PluginResult, -) -from cpex.framework.observability import ObservabilityProvider, current_trace_id -from cpex.framework.registry import PluginInstanceRegistry -from cpex.framework.settings import settings -from cpex.framework.utils import payload_matches - -# Use standard logging to avoid circular imports (plugins -> services -> plugins) -logger = logging.getLogger(__name__) - -# Configuration constants -DEFAULT_PLUGIN_TIMEOUT = 30 # seconds -MAX_PAYLOAD_SIZE = 1_000_000 # 1MB -CONTEXT_CLEANUP_INTERVAL = 300 # 5 minutes -CONTEXT_MAX_AGE = 3600 # 1 hour -HTTP_AUTH_CHECK_PERMISSION_HOOK = "http_auth_check_permission" - -# Metadata constants -DECISION_PLUGIN_METADATA_KEY = "_decision_plugin" -RESERVED_INTERNAL_METADATA_KEYS = frozenset({DECISION_PLUGIN_METADATA_KEY}) - - -@dataclass -class ExecutionContext: - """Per-call mutable state for one PluginExecutor.execute() invocation. - - Attributes: - max_retry_delay_ms: Largest retry delay requested by any plugin in the - chain. Returned to the caller via PluginResult.retry_delay_ms. - hook_chain_executed: Count of plugins that ran (used for observability). - hook_chain_skipped: Count of plugins that were skipped (statically - disabled, runtime-disabled, or conditions unmet). - hook_chain_stopped_by: Name of the plugin that halted the pipeline, - or None if the chain ran to completion. - hook_chain_span_id: Observability span id for the hook-chain span, - or None if observability is unavailable. - """ - - max_retry_delay_ms: int = 0 - hook_chain_executed: int = 0 - hook_chain_skipped: int = 0 - hook_chain_stopped_by: Optional[str] = None - hook_chain_span_id: Optional[str] = None - - -@dataclass -class PhaseState: - """State accumulated during a serial execution phase. - - Replaces the nested tuple return type from _run_serial_phase, - improving readability and self-documentation. - - Attributes: - payload: The current effective payload (may be modified by plugins). - decision_plugin: Name of the last plugin that modified the payload. - extensions: The current extensions (may be modified by plugins). - """ - - payload: Optional[PluginPayload] = None - decision_plugin: Optional[str] = None - extensions: Optional[Extensions] = None - - -class PluginTimeoutError(Exception): - """Raised when a plugin execution exceeds the timeout limit.""" - - -class PayloadSizeError(ValueError): - """Raised when a payload exceeds the maximum allowed size.""" - - -class PluginExecutor: - """Executes a list of plugins with timeout protection and error handling. - - This class manages the execution of plugins in priority order, handling: - - Timeout protection for each plugin - - Context management between plugins - - Error isolation to prevent plugin failures from affecting the gateway - - Metadata aggregation from multiple plugins - - Examples: - >>> executor = PluginExecutor() - >>> # In async context: - >>> # result, contexts = await executor.execute( - >>> # plugins=[plugin1, plugin2], - >>> # payload=payload, - >>> # global_context=context, - >>> # plugin_run=pre_prompt_fetch, - >>> # compare=pre_prompt_matches - >>> # ) - """ - - def __init__( - self, - config: Optional[Config] = None, - timeout: int = DEFAULT_PLUGIN_TIMEOUT, - observability: Optional[ObservabilityProvider] = None, - hook_policies: Optional[dict[str, HookPayloadPolicy]] = None, - default_hook_policy: Optional[Literal["allow", "deny"]] = None, - ): - """Initialize the plugin executor. - - Args: - config: the plugin manager configuration. - timeout: Maximum execution time per plugin in seconds. - observability: Optional observability provider implementing ObservabilityProvider protocol. - hook_policies: Per-hook-type payload modification policies. - default_hook_policy: Fallback hook policy ("allow", "denied") when a policy is not specified - for a hook type (overrides `settings.default_hook_policy`). - """ - self.timeout = timeout - self.config = config - self.observability = observability - self.hook_policies: dict[str, HookPayloadPolicy] = hook_policies or {} - self.default_hook_policy = DefaultHookPolicy( - default_hook_policy if default_hook_policy else settings.default_hook_policy - ) - # Persistent-per-executor: plugins that hit OnError.DISABLE stay out of rotation - # for the lifetime of this executor. Multi-tenant isolation is provided by - # TenantPluginManager (each tenant owns its own executor). Mutations are guarded - # by _runtime_disabled_lock; the membership read in _group_by_mode is unguarded - # because set.__contains__ is atomic under the GIL. - self._runtime_disabled: set[str] = set() - self._runtime_disabled_lock = asyncio.Lock() - - async def execute( - self, - hook_refs: list[HookRef], - payload: PluginPayload, - global_context: GlobalContext, - hook_type: str, - local_contexts: Optional[PluginContextTable] = None, - violations_as_exceptions: bool = False, - extensions: Optional[Extensions] = None, - ) -> tuple[PluginResult, PluginContextTable | None]: - """Execute plugins in priority order with timeout protection. - - Args: - hook_refs: List of hook references to execute, sorted by priority. - payload: The payload to be processed by plugins. - global_context: Shared context for all plugins containing request metadata. - hook_type: The hook type identifier (e.g., "tool_pre_invoke"). - local_contexts: Optional existing contexts from previous hook executions. - violations_as_exceptions: Raise violations as exceptions rather than as returns. - extensions: Optional extensions to filter and pass to plugins that accept them. - - Returns: - A tuple containing: - - PluginResult with processing status, modified payload, and metadata - - PluginContextTable with updated local contexts for each plugin - - Raises: - PayloadSizeError: If the payload exceeds MAX_PAYLOAD_SIZE. - PluginError: If there is an error inside a plugin. - PluginViolationError: If a violation occurs and violation_as_exceptions is set. - - Examples: - >>> # Execute plugins with timeout protection - >>> from cpex.framework.hooks.prompts import PromptHookType - >>> executor = PluginExecutor(timeout=30) - >>> # Assuming you have a registry instance: - >>> # plugins = registry.get_plugins_for_hook(PromptHookType.PROMPT_PRE_FETCH) - >>> # In async context: - >>> # result, contexts = await executor.execute( - >>> # plugins=plugins, - >>> # payload=PromptPrehookPayload(prompt_id="123", name="test", args={}), - >>> # global_context=GlobalContext(request_id="123"), - >>> # plugin_run=pre_prompt_fetch, - >>> # compare=pre_prompt_matches - >>> # ) - """ - if not hook_refs: - return (PluginResult(modified_payload=None), None) - - # Validate payload size - self._validate_payload_size(payload) - - # Look up the policy for this hook type (may be None) - policy = self.hook_policies.get(hook_type) - - res_local_contexts = {} - combined_metadata: dict[str, Any] = {} - current_payload: PluginPayload | None = None - current_extensions: Extensions | None = None - decision_plugin_name: Optional[str] = None - ctx = ExecutionContext() - - # Start hook-chain observability span - trace_id = current_trace_id.get() - if trace_id and self.observability: - try: - ctx.hook_chain_span_id = self.observability.start_span( - trace_id=trace_id, - name="plugin.hook.invoke", - kind="internal", - attributes={ - "plugin.hook.type": hook_type, - "plugin.chain.length": len(hook_refs), - }, - ) - except Exception as e: - logger.debug("Hook-chain observability start_span failed: %s", e) - - sequential_refs, transform_refs, audit_refs, concurrent_refs, fire_and_forget_refs = self._group_by_mode( - hook_refs, payload, hook_type, global_context, ctx - ) - - # Independent semaphores prevent one mode from starving the other - pool = int(settings.execution_pool) if settings.execution_pool else None - fire_and_forget_semaphore = asyncio.Semaphore(pool) if pool else None - concurrent_semaphore = asyncio.Semaphore(pool) if pool else None - - # SEQUENTIAL: sequential, chained execution — can halt pipeline - halt_result, phase = await self._run_serial_phase( - hook_refs=sequential_refs, - mode_label="SEQUENTIAL", - payload=payload, - policy=policy, - hook_type=hook_type, - global_context=global_context, - local_contexts=local_contexts, - res_local_contexts=res_local_contexts, - violations_as_exceptions=violations_as_exceptions, - combined_metadata=combined_metadata, - current_payload=current_payload, - decision_plugin_name=decision_plugin_name, - apply_modifications=True, - allow_blocking=True, - ctx=ctx, - current_extensions=current_extensions, - fire_and_forget_refs=fire_and_forget_refs, - fire_and_forget_semaphore=fire_and_forget_semaphore, - extensions=extensions, - ) - current_payload = phase.payload - decision_plugin_name = phase.decision_plugin - current_extensions = phase.extensions - if halt_result is not None: - self._end_hook_chain_span(ctx, status="ok") - return halt_result - - # TRANSFORM: serial, chained execution — can modify payloads but cannot halt pipeline - _, phase = await self._run_serial_phase( - hook_refs=transform_refs, - mode_label="TRANSFORM", - payload=payload, - policy=policy, - hook_type=hook_type, - global_context=global_context, - local_contexts=local_contexts, - res_local_contexts=res_local_contexts, - violations_as_exceptions=violations_as_exceptions, - combined_metadata=combined_metadata, - current_payload=current_payload, - decision_plugin_name=decision_plugin_name, - apply_modifications=True, - allow_blocking=False, - ctx=ctx, - current_extensions=current_extensions, - extensions=extensions, - ) - current_payload = phase.payload - decision_plugin_name = phase.decision_plugin - current_extensions = phase.extensions - - # AUDIT: serial execution — observe-only (no modifications, no blocking) - _, phase = await self._run_serial_phase( - hook_refs=audit_refs, - mode_label="AUDIT", - payload=payload, - policy=policy, - hook_type=hook_type, - global_context=global_context, - local_contexts=local_contexts, - res_local_contexts=res_local_contexts, - violations_as_exceptions=violations_as_exceptions, - combined_metadata=combined_metadata, - current_payload=current_payload, - decision_plugin_name=decision_plugin_name, - apply_modifications=False, - allow_blocking=False, - ctx=ctx, - current_extensions=current_extensions, - extensions=extensions, - ) - - # CONCURRENT: parallel execution with fail-fast on first blocking result - if concurrent_refs: - concurrent_ctx_list: list[tuple[HookRef, PluginContext, PluginPayload]] = [] - concurrent_tasks: list[asyncio.Task] = [] - effective_payload = current_payload if current_payload is not None else payload - for ref in concurrent_refs: - plugin_input = self._isolate_payload(effective_payload, policy) - local_context = self._prepare_plugin_context(ref, global_context, local_contexts, res_local_contexts) - idx = len(concurrent_ctx_list) - concurrent_ctx_list.append((ref, local_context, effective_payload)) - coro = self.execute_plugin( - ref, - plugin_input, - local_context, - violations_as_exceptions, - global_context, - combined_metadata, - extensions=extensions, - ) - if concurrent_semaphore: - coro = self._with_semaphore(concurrent_semaphore, coro) - concurrent_tasks.append(asyncio.create_task(self._tagged(coro, idx))) - - for completed_coro in asyncio.as_completed(concurrent_tasks): - result, idx = await completed_coro - ref, _, _ = concurrent_ctx_list[idx] - ctx.hook_chain_executed += 1 - # Propagate retry signal from concurrent plugins - ctx.max_retry_delay_ms = max(ctx.max_retry_delay_ms, result.retry_delay_ms) - if result.modified_payload is not None: - logger.debug( - "CONCURRENT plugin %s returned modified_payload on hook %s; " - "discarding (concurrent plugins cannot modify payloads)", - ref.plugin_ref.name, - hook_type, - ) - if not result.continue_processing: - pending = sum(1 for t in concurrent_tasks if not t.done()) - violation_detail = ( - f": [{result.violation.code}] {result.violation.reason}" if result.violation else "" - ) - logger.warning( - "Pipeline halted by CONCURRENT plugin %s on hook %s%s; cancelling %d pending task(s)", - ref.plugin_ref.name, - hook_type, - violation_detail, - pending, - ) - for task in concurrent_tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*concurrent_tasks, return_exceptions=True) - ctx.hook_chain_stopped_by = ref.plugin_ref.name - halt = self._build_halt_result( - current_payload, - result.violation, - combined_metadata, - fire_and_forget_refs, - payload, - global_context, - res_local_contexts, - fire_and_forget_semaphore, - hook_type, - decision_plugin_name, - extensions=extensions, - ) - self._end_hook_chain_span(ctx, status="ok") - return halt - - # FIRE_AND_FORGET: fire-and-forget background tasks (fires last with final payload snapshot) - bg_tasks = self._fire_and_forget_tasks( - fire_and_forget_refs, - payload, - global_context, - res_local_contexts, - fire_and_forget_semaphore, - extensions=extensions, - ) - - if hook_type == HTTP_AUTH_CHECK_PERMISSION_HOOK and decision_plugin_name: - combined_metadata[DECISION_PLUGIN_METADATA_KEY] = decision_plugin_name - - self._end_hook_chain_span(ctx, status="ok") - - return ( - PluginResult( - continue_processing=True, - modified_payload=current_payload, - modified_extensions=current_extensions, - violation=None, - metadata=combined_metadata, - background_tasks=bg_tasks, - retry_delay_ms=ctx.max_retry_delay_ms, - ), - res_local_contexts, - ) - - def _group_by_mode( - self, - hook_refs: list[HookRef], - payload: PluginPayload, - hook_type: str, - global_context: GlobalContext, - ctx: ExecutionContext, - ) -> tuple[list[HookRef], list[HookRef], list[HookRef], list[HookRef], list[HookRef]]: - """Group hook references by mode, filtering disabled and condition-unmatched plugins. - - Args: - hook_refs: All hook references to evaluate. - payload: The current payload (used for condition matching). - hook_type: The hook type identifier. - global_context: Shared context for condition evaluation. - ctx: Per-call execution context; skip count is accumulated here. - - Returns: - A tuple of (sequential_refs, transform_refs, audit_refs, concurrent_refs, - fire_and_forget_refs), each sorted by priority. - """ - sequential_refs: list[HookRef] = [] - transform_refs: list[HookRef] = [] - audit_refs: list[HookRef] = [] - concurrent_refs: list[HookRef] = [] - fire_and_forget_refs: list[HookRef] = [] - - for ref in hook_refs: - # Skip statically disabled plugins - if ref.plugin_ref.mode == PluginMode.DISABLED: - logger.debug("Skipping plugin %s — statically disabled", ref.plugin_ref.name) - ctx.hook_chain_skipped += 1 - continue - # Skip runtime-disabled plugins - if ref.plugin_ref.name in self._runtime_disabled: - logger.debug("Skipping plugin %s — runtime-disabled after previous error", ref.plugin_ref.name) - ctx.hook_chain_skipped += 1 - continue - # Check conditions - if ref.plugin_ref.conditions and not payload_matches( - payload, hook_type, ref.plugin_ref.conditions, global_context - ): - logger.debug("Skipping plugin %s - conditions not met", ref.plugin_ref.name) - ctx.hook_chain_skipped += 1 - continue - # Bucket by mode - if ref.plugin_ref.mode == PluginMode.SEQUENTIAL: - sequential_refs.append(ref) - elif ref.plugin_ref.mode == PluginMode.TRANSFORM: - transform_refs.append(ref) - elif ref.plugin_ref.mode == PluginMode.AUDIT: - audit_refs.append(ref) - elif ref.plugin_ref.mode == PluginMode.CONCURRENT: - concurrent_refs.append(ref) - elif ref.plugin_ref.mode == PluginMode.FIRE_AND_FORGET: - fire_and_forget_refs.append(ref) - - sequential_refs.sort(key=lambda r: r.plugin_ref.priority) - transform_refs.sort(key=lambda r: r.plugin_ref.priority) - audit_refs.sort(key=lambda r: r.plugin_ref.priority) - concurrent_refs.sort(key=lambda r: r.plugin_ref.priority) - fire_and_forget_refs.sort(key=lambda r: r.plugin_ref.priority) - - return sequential_refs, transform_refs, audit_refs, concurrent_refs, fire_and_forget_refs - - def _end_hook_chain_span(self, ctx: ExecutionContext, status: str = "ok") -> None: - """End the hook-chain observability span with accumulated counters.""" - if ctx.hook_chain_span_id is not None and self.observability: - try: - self.observability.end_span( - span_id=ctx.hook_chain_span_id, - status=status, - attributes={ - "plugin.executed_count": ctx.hook_chain_executed, - "plugin.skipped_count": ctx.hook_chain_skipped, - "plugin.chain.stopped": ctx.hook_chain_stopped_by is not None, - "plugin.chain.stopped_by": ctx.hook_chain_stopped_by or "", - }, - ) - except Exception as e: - logger.debug("Hook-chain observability end_span failed: %s", e) - ctx.hook_chain_span_id = None - - async def _run_serial_phase( - self, - hook_refs: list[HookRef], - mode_label: str, - payload: PluginPayload, - policy: Any, - hook_type: str, - global_context: GlobalContext, - local_contexts: Optional[PluginContextTable], - res_local_contexts: dict, - violations_as_exceptions: bool, - combined_metadata: dict[str, Any], - current_payload: Optional[PluginPayload], - decision_plugin_name: Optional[str], - apply_modifications: bool, - allow_blocking: bool, - ctx: ExecutionContext, - current_extensions: Optional[Extensions] = None, - fire_and_forget_refs: Optional[list[HookRef]] = None, - fire_and_forget_semaphore: Optional[asyncio.Semaphore] = None, - extensions: Optional[Extensions] = None, - ) -> tuple[ - Optional[tuple[PluginResult, PluginContextTable | None]], - PhaseState, - ]: - """Run a serial execution phase (SEQUENTIAL, TRANSFORM, or AUDIT). - - Args: - hook_refs: Hook references to execute in priority order. - mode_label: Human-readable mode name for log messages. - payload: The original (unmodified) payload. - policy: Hook payload policy for field filtering. - hook_type: The hook type identifier. - global_context: Shared context for all plugins. - local_contexts: Existing contexts from previous hook executions. - res_local_contexts: Accumulator for local contexts produced in this execution. - violations_as_exceptions: Whether to raise violations as exceptions. - combined_metadata: Accumulator for plugin metadata. - current_payload: The current effective payload (may be None). - decision_plugin_name: Name of the plugin that last modified the payload. - apply_modifications: Whether to apply payload modifications from plugins. - allow_blocking: Whether plugins can halt the pipeline. - ctx: Per-call execution context; counters and stop reason accumulate here. - fire_and_forget_refs: Fire-and-forget refs to schedule on halt (only used when allow_blocking=True). - fire_and_forget_semaphore: Semaphore for fire-and-forget tasks (only used when allow_blocking=True). - - Returns: - A tuple of (halt_result, phase_state). halt_result is None if pipeline continues. - """ - for hook_ref in hook_refs: - local_context = self._prepare_plugin_context(hook_ref, global_context, local_contexts, res_local_contexts) - effective_payload = current_payload if current_payload is not None else payload - plugin_input = self._isolate_payload(effective_payload, policy) - - result = await self.execute_plugin( - hook_ref, - plugin_input, - local_context, - violations_as_exceptions, - global_context, - combined_metadata, - extensions=extensions, - ) - ctx.hook_chain_executed += 1 - - # Propagate retry signal — take the largest delay requested by any plugin - ctx.max_retry_delay_ms = max(ctx.max_retry_delay_ms, result.retry_delay_ms) - - if result.modified_payload is not None: - if apply_modifications: - current_payload, decision_plugin_name = self._apply_payload_modification( - hook_ref, - result, - plugin_input, - policy, - hook_type, - current_payload, - decision_plugin_name, - apply_to=effective_payload, - ) - else: - logger.debug( - "%s plugin %s returned modified_payload on hook %s; discarding (%s is observe-only)", - mode_label, - hook_ref.plugin_ref.name, - hook_type, - mode_label.lower(), - ) - - # Accumulate modified_extensions (last writer wins) - if result.modified_extensions is not None: - current_extensions = result.modified_extensions - - if not result.continue_processing: - violation_detail = f": [{result.violation.code}] {result.violation.reason}" if result.violation else "" - if allow_blocking: - logger.warning( - "Pipeline halted by %s plugin %s on hook %s%s; scheduling fire-and-forget tasks", - mode_label, - hook_ref.plugin_ref.name, - hook_type, - violation_detail, - ) - ctx.hook_chain_stopped_by = hook_ref.plugin_ref.name - state = PhaseState( - payload=current_payload, decision_plugin=decision_plugin_name, extensions=current_extensions - ) - halt = self._build_halt_result( - current_payload, - result.violation, - combined_metadata, - fire_and_forget_refs or [], - payload, - global_context, - res_local_contexts, - fire_and_forget_semaphore, - hook_type, - decision_plugin_name, - extensions=extensions, - ) - return halt, state - else: - logger.warning( - "%s plugin %s returned continue_processing=False on hook %s%s; " - "pipeline continues (blocking suppressed)", - mode_label, - hook_ref.plugin_ref.name, - hook_type, - violation_detail, - ) - - return None, PhaseState( - payload=current_payload, decision_plugin=decision_plugin_name, extensions=current_extensions - ) - - def _apply_payload_modification( - self, - hook_ref: HookRef, - result: PluginResult, - effective_payload: PluginPayload, - policy: Any, - hook_type: str, - current_payload: Optional[PluginPayload], - decision_plugin_name: Optional[str], - *, - apply_to: Optional[PluginPayload] = None, - ) -> tuple[Optional[PluginPayload], Optional[str]]: - """Apply a plugin's payload modification, respecting the hook policy. - - Args: - effective_payload: The baseline payload the plugin received (may be - an isolated/CoW copy). Used for diffing to detect changes. - apply_to: The canonical pipeline payload to merge accepted changes - into. When ``None``, changes are applied to *effective_payload*. - - Returns: - Updated (current_payload, decision_plugin_name) tuple. - """ - if policy: - if isinstance(result.modified_payload, type(effective_payload)) and isinstance( - effective_payload, BaseModel - ): - # Same-type BaseModel payload — apply field-level policy filtering - filtered = apply_policy(effective_payload, result.modified_payload, policy, apply_to=apply_to) - if filtered is not None: - return filtered, hook_ref.plugin_ref.name - else: - # Cross-type payload — guard: only accept PluginPayload subtypes or dict - if isinstance(result.modified_payload, (PluginPayload, dict)): - logger.debug( - "Plugin %s returned cross-type payload (%s -> %s) on hook %s; accepting without field filtering", - hook_ref.plugin_ref.name, - type(effective_payload).__name__, - type(result.modified_payload).__name__, - hook_type, - ) - return result.modified_payload, hook_ref.plugin_ref.name - else: - logger.warning( - "Plugin %s returned unexpected type %s on hook %s; ignoring modification", - hook_ref.plugin_ref.name, - type(result.modified_payload).__name__, - hook_type, - ) - elif self.default_hook_policy == DefaultHookPolicy.ALLOW: - # No explicit policy + default=allow -- accept all modifications - return result.modified_payload, hook_ref.plugin_ref.name - else: - # No explicit policy + default=deny -- reject all modifications - logger.warning( - "Plugin %s attempted payload modification on hook %s but no policy is defined and default is deny", - hook_ref.plugin_ref.name, - hook_type, - ) - return current_payload, decision_plugin_name - - def _prepare_plugin_context( - self, - hook_ref: HookRef, - global_context: GlobalContext, - local_contexts: Optional[PluginContextTable], - res_local_contexts: dict, - ) -> PluginContext: - """Create an isolated GlobalContext copy and resolve or create the PluginContext. - - The resolved context is stored in *res_local_contexts* as a side effect. - """ - local_context_key = global_context.request_id + hook_ref.plugin_ref.uuid - tmp_gc = GlobalContext( - request_id=global_context.request_id, - user=global_context.user, - user_context=global_context.user_context, - tenant_id=global_context.tenant_id, - server_id=global_context.server_id, - content_type=global_context.content_type, - state={} if not global_context.state else copyonwrite(global_context.state), - metadata={} if not global_context.metadata else copyonwrite(global_context.metadata), - ) - if local_contexts and local_context_key in local_contexts: - local_context = local_contexts[local_context_key] - local_context.global_context = tmp_gc - else: - local_context = PluginContext(global_context=tmp_gc) - res_local_contexts[local_context_key] = local_context - return local_context - - def _isolate_payload( - self, - effective_payload: PluginPayload, - policy: Any, - ) -> PluginPayload: - """Return an isolated copy of the payload when policy or defaults demand it. - - Copy-on-write wrapping is used for BaseModel payloads; other types are deep-copied. - When no isolation is required the original payload is returned as-is. - """ - needs_isolation = ( - policy or self.default_hook_policy == DefaultHookPolicy.DENY or isinstance(effective_payload, RootModel) - ) - if not needs_isolation: - return effective_payload - if isinstance(effective_payload, BaseModel): - return wrap_payload_for_isolation(effective_payload) - return _safe_deepcopy(effective_payload) - - def _build_halt_result( - self, - current_payload: Optional[PluginPayload], - violation: Any, - combined_metadata: dict[str, Any], - fire_and_forget_refs: list[HookRef], - payload: PluginPayload, - global_context: GlobalContext, - res_local_contexts: dict, - fire_and_forget_semaphore: Optional[asyncio.Semaphore], - hook_type: str, - decision_plugin_name: Optional[str], - extensions: Optional[Extensions] = None, - ) -> tuple[PluginResult, dict]: - """Schedule fire-and-forget tasks and build a pipeline-halting result.""" - bg_tasks = self._fire_and_forget_tasks( - fire_and_forget_refs, - payload, - global_context, - res_local_contexts, - fire_and_forget_semaphore, - extensions=extensions, - ) - if hook_type == HTTP_AUTH_CHECK_PERMISSION_HOOK and decision_plugin_name: - combined_metadata[DECISION_PLUGIN_METADATA_KEY] = decision_plugin_name - return ( - PluginResult( - continue_processing=False, - modified_payload=current_payload, - violation=violation, - metadata=combined_metadata, - background_tasks=bg_tasks, - ), - res_local_contexts, - ) - - @staticmethod - async def _with_semaphore(semaphore: asyncio.Semaphore, coro: Any) -> Any: - """Await *coro* while holding *semaphore*, bounding concurrent CONCURRENT tasks.""" - async with semaphore: - return await coro - - @staticmethod - async def _tagged(coro: Any, tag: Any) -> tuple[Any, Any]: - """Await *coro* and pair the result with *tag* for use with as_completed.""" - result = await coro - return result, tag - - def _fire_and_forget_tasks( - self, - fire_and_forget_refs: list[HookRef], - payload: PluginPayload, - global_context: GlobalContext, - res_local_contexts: dict, - semaphore: Optional[asyncio.Semaphore], - extensions: Optional[Extensions] = None, - ) -> list[asyncio.Task]: - """Schedule all FIRE_AND_FORGET plugins as fire-and-forget background tasks. - - May be called from an early-exit path or from the normal completion path. - Each FIRE_AND_FORGET plugin receives an isolated snapshot of the payload at call time. - Returns the list of asyncio.Task handles for all newly scheduled tasks. - """ - tasks: list[asyncio.Task] = [] - for ref in fire_and_forget_refs: - local_context_key = global_context.request_id + ref.plugin_ref.uuid - if local_context_key in res_local_contexts: - # Already scheduled — skip to avoid double-scheduling - continue - task_input = ( - wrap_payload_for_isolation(payload) if isinstance(payload, BaseModel) else _safe_deepcopy(payload) - ) - tmp_gc = GlobalContext( - request_id=global_context.request_id, - user=global_context.user, - user_context=global_context.user_context, - tenant_id=global_context.tenant_id, - server_id=global_context.server_id, - content_type=global_context.content_type, - state={} if not global_context.state else copyonwrite(global_context.state), - metadata={} if not global_context.metadata else copyonwrite(global_context.metadata), - ) - local_context = PluginContext(global_context=tmp_gc) - res_local_contexts[local_context_key] = local_context - task = asyncio.create_task( - self._run_fire_and_forget_task(ref, task_input, local_context, semaphore, extensions=extensions) - ) - tasks.append(task) - return tasks - - async def _run_fire_and_forget_task( - self, - hook_ref: HookRef, - payload: PluginPayload, - local_context: PluginContext, - semaphore: Optional[asyncio.Semaphore], - extensions: Optional[Extensions] = None, - ) -> Optional[PluginErrorModel]: - """Execute a plugin as a fire-and-forget background task. - - Returns None on success, or a PluginErrorModel if the plugin raised. - Errors are logged but never propagated — background tasks cannot halt the pipeline. - If on_error=DISABLE, the plugin is added to the runtime-disabled set. - """ - try: - if semaphore: - async with semaphore: - await self._execute_with_timeout(hook_ref, payload, local_context, extensions=extensions) - else: - await self._execute_with_timeout(hook_ref, payload, local_context, extensions=extensions) - return None - except Exception as exc: - logger.error("Plugin %s failed in fire-and-forget mode (ignored)", hook_ref.plugin_ref.name) - if hook_ref.plugin_ref.on_error == OnError.DISABLE: - async with self._runtime_disabled_lock: - self._runtime_disabled.add(hook_ref.plugin_ref.name) - # FAIL and IGNORE both just log for FIRE_AND_FORGET mode (background can't halt pipeline) - return PluginErrorModel(message=repr(exc), plugin_name=hook_ref.plugin_ref.name) - - async def execute_plugin( - self, - hook_ref: HookRef, - payload: PluginPayload, - local_context: PluginContext, - violations_as_exceptions: bool, - global_context: Optional[GlobalContext] = None, - combined_metadata: Optional[dict[str, Any]] = None, - extensions: Optional[Extensions] = None, - ) -> PluginResult: - """Execute a single plugin with timeout protection. - - Args: - hook_ref: Hooking structure that contains the plugin and hook. - payload: The payload to be processed by plugins. - local_context: local context. - violations_as_exceptions: Raise violations as exceptions rather than as returns. - global_context: Shared context for all plugins containing request metadata. - combined_metadata: combination of the metadata of all plugins. - extensions: Optional extensions to filter and pass to plugins that accept them. - - Returns: - A tuple containing: - - PluginResult with processing status, modified payload, and metadata - - PluginContextTable with updated local contexts for each plugin - - Raises: - PayloadSizeError: If the payload exceeds MAX_PAYLOAD_SIZE. - PluginError: If there is an error inside a plugin. - PluginViolationError: If a violation occurs and violation_as_exceptions is set. - """ - try: - # Execute plugin with timeout protection - result = await self._execute_with_timeout(hook_ref, payload, local_context, extensions=extensions) - # Merge global state for modes that participate in the pipeline chain. - # AUDIT and FIRE_AND_FORGET operate on isolated snapshots and should not - # mutate shared state. - if ( - local_context.global_context - and global_context - and hook_ref.plugin_ref.mode - in ( - PluginMode.SEQUENTIAL, - PluginMode.TRANSFORM, - PluginMode.CONCURRENT, - ) - ): - global_context.state.update(local_context.global_context.state) - global_context.metadata.update(local_context.global_context.metadata) - # Aggregate metadata from all plugins - if result.metadata and combined_metadata is not None: - combined_metadata.update( - {k: v for k, v in result.metadata.items() if k not in RESERVED_INTERNAL_METADATA_KEYS} - ) - - # Set plugin name in violation if present - if result.violation: - result.violation.plugin_name = hook_ref.plugin_ref.plugin.name - - # Handle plugin blocking the request - if not result.continue_processing: - if hook_ref.plugin_ref.mode in (PluginMode.CONCURRENT, PluginMode.SEQUENTIAL): - mode = hook_ref.plugin_ref.mode.value - if result.violation: - logger.warning( - "Plugin %s blocked request in %s mode — violation [%s] %s: %s", - hook_ref.plugin_ref.plugin.name, - mode, - result.violation.code, - result.violation.reason, - result.violation.description, - ) - else: - logger.warning( - "Plugin %s blocked request in %s mode (no violation details)", - hook_ref.plugin_ref.plugin.name, - mode, - ) - if violations_as_exceptions: - if result.violation: - plugin_name = result.violation.plugin_name - violation_reason = result.violation.reason - violation_desc = result.violation.description - violation_code = result.violation.code - raise PluginViolationError( - f"{hook_ref.name} blocked by plugin {plugin_name}: {violation_code} - {violation_reason} ({violation_desc})", - violation=result.violation, - ) - raise PluginViolationError(f"{hook_ref.name} blocked by plugin") - return PluginResult( - continue_processing=False, - modified_payload=None, - violation=result.violation, - metadata=combined_metadata, - ) - if hook_ref.plugin_ref.mode in (PluginMode.AUDIT, PluginMode.TRANSFORM): - mode_label = hook_ref.plugin_ref.mode.value - if result.violation: - logger.warning( - "Plugin %s (%s) raised violation — pipeline continues: [%s] %s — %s", - hook_ref.plugin_ref.plugin.name, - mode_label, - result.violation.code, - result.violation.reason, - result.violation.description, - ) - else: - logger.warning( - "Plugin %s (%s) returned continue_processing=False without a violation " - "— pipeline continues", - hook_ref.plugin_ref.plugin.name, - mode_label, - ) - # Violations are logged but not propagated; AUDIT and TRANSFORM - # plugins cannot halt the pipeline. TRANSFORM may still carry a - # modified_payload (applied by the caller); AUDIT never does. - forwarded_payload = ( - result.modified_payload if hook_ref.plugin_ref.mode == PluginMode.TRANSFORM else None - ) - return PluginResult( - continue_processing=True, - modified_payload=forwarded_payload, - violation=None, - metadata=combined_metadata, - ) - return result - except asyncio.TimeoutError as exc: - on_error = hook_ref.plugin_ref.on_error - logger.error("Plugin %s timed out after %ds", hook_ref.plugin_ref.name, self.timeout) - if on_error == OnError.FAIL: - raise PluginError( - error=PluginErrorModel( - message=f"Plugin {hook_ref.plugin_ref.name} exceeded {self.timeout}s timeout", - plugin_name=hook_ref.plugin_ref.name, - ) - ) from exc - if on_error == OnError.DISABLE: - async with self._runtime_disabled_lock: - self._runtime_disabled.add(hook_ref.plugin_ref.name) - except PluginViolationError: - raise - except PluginError as pe: - on_error = hook_ref.plugin_ref.on_error - logger.error("Plugin %s failed with error: %s", hook_ref.plugin_ref.name, str(pe)) - if on_error == OnError.FAIL: - raise - if on_error == OnError.DISABLE: - async with self._runtime_disabled_lock: - self._runtime_disabled.add(hook_ref.plugin_ref.name) - except Exception as e: - on_error = hook_ref.plugin_ref.on_error - logger.error("Plugin %s failed with error: %s", hook_ref.plugin_ref.name, str(e)) - if on_error == OnError.FAIL: - raise PluginError(error=convert_exception_to_error(e, hook_ref.plugin_ref.name)) from e - if on_error == OnError.DISABLE: - async with self._runtime_disabled_lock: - self._runtime_disabled.add(hook_ref.plugin_ref.name) - # Return a result indicating processing should continue despite the error - return PluginResult(continue_processing=True) - - async def reset_runtime_disabled(self) -> None: - """Clear the runtime-disabled plugin set. - - Intended for tests and operational reset (e.g., after the underlying error - condition has been addressed and previously-disabled plugins should be - re-enabled without restarting the process). Acquires the same lock used - by the disable path, so it is safe to call concurrently with hook dispatch. - """ - async with self._runtime_disabled_lock: - self._runtime_disabled.clear() - - async def _execute_with_timeout( - self, - hook_ref: HookRef, - payload: PluginPayload, - context: PluginContext, - extensions: Optional[Extensions] = None, - ) -> PluginResult: - """Execute a plugin with timeout protection. - - Args: - hook_ref: Reference to the hook and plugin to execute. - payload: Payload to process. - context: Plugin execution context. - extensions: Optional extensions to filter and pass if the plugin accepts them. - - Returns: - Result from plugin execution. - - Raises: - asyncio.TimeoutError: If plugin exceeds timeout. - asyncio.CancelledError: If plugin execution is cancelled. - Exception: Re-raised from plugin hook execution failures. - """ - # Start observability span if tracing is active - trace_id = current_trace_id.get() - span_id = None - - if trace_id and self.observability: - try: - span_id = self.observability.start_span( - trace_id=trace_id, - name=f"plugin.execute.{hook_ref.plugin_ref.name}", - kind="internal", - resource_type="plugin", - resource_name=hook_ref.plugin_ref.name, - attributes={ - "plugin.name": hook_ref.plugin_ref.name, - "plugin.uuid": hook_ref.plugin_ref.uuid, - "plugin.mode": ( - hook_ref.plugin_ref.mode.value - if hasattr(hook_ref.plugin_ref.mode, "value") - else str(hook_ref.plugin_ref.mode) - ), - "plugin.priority": hook_ref.plugin_ref.priority, - "plugin.timeout": self.timeout, - }, - ) - except Exception as e: - logger.debug("Plugin observability start_span failed: %s", e) - - # Execute plugin - try: - if hook_ref.accepts_extensions: - filtered = filter_extensions(extensions, hook_ref.plugin_ref.capabilities) - result = await asyncio.wait_for(hook_ref.hook(payload, context, filtered), timeout=self.timeout) - else: - result = await asyncio.wait_for(hook_ref.hook(payload, context), timeout=self.timeout) - except Exception: - if span_id is not None: - try: - self.observability.end_span(span_id=span_id, status="error") - except Exception: # nosec B110 - pass - raise - - # End span with success - if span_id is not None: - try: - self.observability.end_span( - span_id=span_id, - status="ok", - attributes={ - "plugin.had_violation": result.violation is not None, - "plugin.modified_payload": result.modified_payload is not None, - }, - ) - except Exception as e: - logger.debug("Plugin observability end_span failed: %s", e) - - return result - - def _validate_payload_size(self, payload: Any) -> None: - """Validate that payload doesn't exceed size limits. - - Args: - payload: The payload to validate. - - Raises: - PayloadSizeError: If payload exceeds MAX_PAYLOAD_SIZE. - """ - # For PromptPrehookPayload, check args size - if hasattr(payload, "args") and payload.args: - total_size = sum(len(str(v)) for v in payload.args.values()) - if total_size > MAX_PAYLOAD_SIZE: - raise PayloadSizeError(f"Payload size {total_size} exceeds limit of {MAX_PAYLOAD_SIZE} bytes") - # For PromptPosthookPayload, check result size - elif hasattr(payload, "result") and payload.result: - # Estimate size of result messages - total_size = len(str(payload.result)) - if total_size > MAX_PAYLOAD_SIZE: - raise PayloadSizeError(f"Result size {total_size} exceeds limit of {MAX_PAYLOAD_SIZE} bytes") - - -class PluginManager: - """Plugin manager for managing the plugin lifecycle. - - This class implements a thread-safe Borg singleton pattern to ensure consistent - plugin management across the application. It handles: - - Plugin discovery and loading from configuration - - Plugin lifecycle management (initialization, execution, shutdown) - - Context management with automatic cleanup - - Hook execution orchestration - - Thread Safety: - Uses double-checked locking to prevent race conditions when multiple threads - create PluginManager instances simultaneously. The first instance to acquire - the lock loads the configuration; subsequent instances reuse the shared state. - - Attributes: - config: The loaded plugin configuration. - plugin_count: Number of currently loaded plugins. - initialized: Whether the manager has been initialized. - - Examples: - >>> # Initialize plugin manager - >>> manager = PluginManager("plugins/config.yaml") - >>> # In async context: - >>> # await manager.initialize() - >>> # print(f"Loaded {manager.plugin_count} plugins") - >>> - >>> # Execute prompt hooks - >>> from cpex.framework.models import GlobalContext - >>> from cpex.framework.hooks.prompts import PromptPrehookPayload - >>> payload = PromptPrehookPayload(prompt_id="123", name="test", args={}) - >>> context = GlobalContext(request_id="req-123") - >>> # In async context: - >>> # result, contexts = await manager.prompt_pre_fetch(payload, context) - >>> - >>> # Shutdown when done - >>> # await manager.shutdown() - """ - - __shared_state: dict[Any, Any] = {} - __lock: threading.Lock = threading.Lock() # Thread safety for synchronous init - _async_lock: asyncio.Lock | None = None # Async lock for initialize/shutdown - _loader: PluginLoader = PluginLoader() - _initialized: bool = False - _registry: PluginInstanceRegistry = PluginInstanceRegistry() - _config: Config | None = None - _config_path: str | None = None - _executor: PluginExecutor | None = None - - def __init__( - self, - config: str = "", - timeout: int = DEFAULT_PLUGIN_TIMEOUT, - observability: Optional[ObservabilityProvider] = None, - hook_policies: Optional[dict[str, HookPayloadPolicy]] = None, - default_hook_policy: Optional[Literal["allow", "deny"]] = None, - ): - """Initialize plugin manager. - - PluginManager implements a thread-safe Borg singleton: - - Shared state is initialized only once across all instances. - - Subsequent instantiations reuse same state and skip config reload. - - Uses double-checked locking to prevent race conditions in multi-threaded environments. - - Thread Safety: - The initialization uses a double-checked locking pattern to ensure that - config loading only happens once, even when multiple threads create - PluginManager instances simultaneously. - - Args: - config: Path to plugin configuration file (YAML). - timeout: Maximum execution time per plugin in seconds. - observability: Optional observability provider implementing ObservabilityProvider protocol. - hook_policies: Per-hook-type payload modification policies (injected by gateway). - default_hook_policy: Fallback hook policy ("allow", "deny") when a policy is not specified - for a hook type (if set, takes precedence over `settings.default_hook_policy`). - - Examples: - >>> # Initialize with configuration file - >>> manager = PluginManager("plugins/config.yaml") - - >>> # Initialize with custom timeout - >>> manager = PluginManager("plugins/config.yaml", timeout=60) - """ - self.__dict__ = self.__shared_state - - # Only initialize once (first instance when shared state is empty) - # Use lock to prevent race condition in multi-threaded environments - if not self.__shared_state: - with self.__lock: - # Double-check after acquiring lock (another thread may have initialized) - if not self.__shared_state: - if config: - self._config = ConfigLoader.load_config(config) - self._config_path = config - - # Update executor with timeout, observability, and policies - self._executor = PluginExecutor( - config=self._config, - timeout=timeout, - observability=observability, - hook_policies=hook_policies, - default_hook_policy=default_hook_policy, - ) - elif hook_policies or default_hook_policy or observability: - # Allow optional arguments to be injected after initial Borg creation. - with self.__lock: - executor = self._get_executor() - # Only update timeout if caller provided a non-default value - if timeout != DEFAULT_PLUGIN_TIMEOUT: - executor.timeout = timeout - if not executor.hook_policies: - executor.hook_policies = hook_policies - elif executor.hook_policies != hook_policies: - logger.warning( - "PluginManager: hook_policies already set; ignoring new policies (call reset() first to replace them)" - ) - if default_hook_policy: - executor.default_hook_policy = DefaultHookPolicy(default_hook_policy) - if observability and not executor.observability: - executor.observability = observability - - def _get_executor(self) -> PluginExecutor: - """Get plugin executor, creating it lazily if necessary. - - Returns: - PluginExecutor: The plugin executor instance. - """ - if self._executor is None: - self._executor = PluginExecutor(config=self._config) - return self._executor - - @property - def executor(self) -> PluginExecutor: - """Expose executor for tests and internal callers. - - Returns: - PluginExecutor: The plugin executor instance. - """ - return self._get_executor() - - @executor.setter - def executor(self, value: PluginExecutor) -> None: - """Set the plugin executor instance. - - Args: - value: The plugin executor to assign. - """ - self._executor = value - - @classmethod - def reset(cls) -> None: - """Reset the Borg pattern shared state. - - This method clears all shared state, allowing a fresh PluginManager - instance to be created with new configuration. Primarily used for testing. - - Thread-safe: Uses lock to ensure atomic reset operation. - - Examples: - >>> # Between tests, reset shared state - >>> PluginManager.reset() - >>> manager = PluginManager("new_config.yaml") - """ - with cls.__lock: - cls.__shared_state.clear() - cls._initialized = False - cls._config = None - cls._config_path = None - cls._async_lock = None - cls._registry = PluginInstanceRegistry() - cls._executor = None - cls._loader = PluginLoader() - - @property - def config(self) -> Config | None: - """Plugin manager configuration. - - Returns: - The plugin configuration object or None if not configured. - """ - return self._config - - @property - def plugin_count(self) -> int: - """Number of plugins loaded. - - Returns: - The number of currently loaded plugins. - """ - return self._registry.plugin_count - - @property - def initialized(self) -> bool: - """Plugin manager initialization status. - - Returns: - True if the plugin manager has been initialized. - """ - return self._initialized - - @property - def observability(self) -> Optional[ObservabilityProvider]: - """Current observability provider. - - Returns: - The observability provider or None if not configured. - """ - return self._executor.observability - - @observability.setter - def observability(self, provider: Optional[ObservabilityProvider]) -> None: - """Set the observability provider. - - Thread-safe: uses lock to prevent races with concurrent readers. - - Args: - provider: ObservabilityProvider to inject into the executor. - """ - with self.__lock: - self._executor.observability = provider - - def get_plugin(self, name: str) -> Optional[Plugin]: - """Get a plugin by name. - - Args: - name: the name of the plugin to return. - - Returns: - A plugin. - """ - plugin_ref = self._registry.get_plugin(name) - return plugin_ref.plugin if plugin_ref else None - - def has_hooks_for(self, hook_type: str) -> bool: - """Check if there are any hooks registered for a specific hook type. - - Args: - hook_type: The type of hook to check for. - - Returns: - True if there are hooks registered for the specified type, False otherwise. - """ - return self._registry.has_hooks_for(hook_type) - - async def initialize(self) -> None: - """Initialize the plugin manager and load all configured plugins. - - This method: - 1. Loads plugin configurations from the config file - 2. Instantiates each enabled plugin - 3. Registers plugins with the registry - 4. Validates plugin initialization - - Thread Safety: - Uses asyncio.Lock to prevent concurrent initialization from multiple - coroutines or async tasks. Combined with threading.Lock in __init__ - for full multi-threaded safety. - - Raises: - RuntimeError: If plugin initialization fails with an exception. - ValueError: If a plugin cannot be initialized or registered. - - Examples: - >>> manager = PluginManager("plugins/config.yaml") - >>> # In async context: - >>> # await manager.initialize() - >>> # Manager is now ready to execute plugins - """ - # Initialize async lock lazily (can't create asyncio.Lock in class definition) - with self.__lock: - if self._async_lock is None: - self._async_lock = asyncio.Lock() - - async with self._async_lock: - # Double-check after acquiring lock - if self._initialized: - logger.debug("Plugin manager already initialized") - return - - # Defensive cleanup: registry should be empty when not initialized - if self._registry.plugin_count: - logger.debug("Plugin registry not empty before initialize; clearing stale plugins") - await self._registry.shutdown() - - # Configure search path based on safe plugin_dirs - plugin_dirs = self._config.plugin_dirs if self._config and self._config.plugin_dirs else [] - self._loader.append_to_search_path(plugin_dirs) - - plugins = self._config.plugins if self._config and self._config.plugins else [] - - loaded_count = 0 - - for plugin_config in plugins: - try: - # For disabled plugins, create a stub plugin without full instantiation - if plugin_config.mode != PluginMode.DISABLED: - # Fully instantiate enabled plugins - plugin = await self._loader.load_and_instantiate_plugin(plugin_config) - if plugin: - # For external plugins, initialize() merges the remote - # config (mode, hooks, etc.) so the post-init config is - # authoritative. For internal plugins the original YAML - # config is already complete. - if plugin_config.kind == EXTERNAL_PLUGIN_TYPE: - trusted = plugin.config.model_copy() - else: - trusted = plugin_config - self._registry.register(plugin, trusted_config=trusted) - loaded_count += 1 - logger.info("Loaded plugin: %s (mode: %s)", plugin_config.name, plugin_config.mode) - else: - raise ValueError(f"Unable to instantiate plugin: {plugin_config.name}") - else: - logger.info("Plugin: %s is disabled. Ignoring.", plugin_config.name) - - except Exception as e: - # Clean error message without stack trace spam - logger.error("Failed to load plugin %s: {%s}", plugin_config.name, str(e)) - if not settings.fail_on_plugin_error: - logger.warning( - "Skipping plugin %s because fail_on_plugin_error is disabled", plugin_config.name - ) - continue - # Let it crash gracefully with a clean error - raise RuntimeError(f"Plugin initialization failed: {plugin_config.name} - {str(e)}") from e - - self._initialized = True - logger.info("Plugin manager initialized with %s plugins", loaded_count) - - async def shutdown(self) -> None: - """Shutdown all plugins and cleanup resources. - - This method: - 1. Shuts down all registered plugins - 2. Clears the plugin registry - 3. Cleans up stored contexts - 4. Resets initialization state - - Thread Safety: - Uses asyncio.Lock to prevent concurrent shutdown with initialization - or with another shutdown call. - - Note: The config is preserved to allow modifying settings and re-initializing. - To fully reset for a new config, create a new PluginManager instance. - - Examples: - >>> manager = PluginManager("plugins/config.yaml") - >>> # In async context: - >>> # await manager.initialize() - >>> # ... use the manager ... - >>> # await manager.shutdown() - """ - # Initialize async lock lazily if needed - with self.__lock: - if self._async_lock is None: - self._async_lock = asyncio.Lock() - - async with self._async_lock: - if not self._initialized: - logger.debug("Plugin manager not initialized, nothing to shutdown") - return - - logger.info("Shutting down plugin manager") - - # Shutdown all plugins - await self._registry.shutdown() - - # Reset state to allow re-initialization - self._initialized = False - - logger.info("Plugin manager shutdown complete") - - async def invoke_hook( - self, - hook_type: str, - payload: PluginPayload, - global_context: GlobalContext, - local_contexts: Optional[PluginContextTable] = None, - violations_as_exceptions: bool = False, - extensions: Optional[Extensions] = None, - ) -> tuple[PluginResult, PluginContextTable | None]: - """Invoke a set of plugins configured for the hook point in priority order. - - Args: - hook_type: The type of hook to execute. - payload: The plugin payload for which the plugins will analyze and modify. - global_context: Shared context for all plugins with request metadata. - local_contexts: Optional existing contexts from previous hook executions. - violations_as_exceptions: Raise violations as exceptions rather than as returns. - extensions: Optional extensions to filter and pass to plugins that accept them. - - Returns: - A tuple containing: - - PluginResult with processing status and modified payload - - PluginContextTable with plugin contexts for state management - - Examples: - >>> manager = PluginManager("plugins/config.yaml") - >>> # In async context: - >>> # await manager.initialize() - >>> # payload = ResourcePreFetchPayload("file:///data.txt") - >>> # context = GlobalContext(request_id="123", server_id="srv1") - >>> # result, contexts = await manager.resource_pre_fetch(payload, context) - >>> # if result.continue_processing: - >>> # # Use modified payload - >>> # uri = result.modified_payload.uri - """ - # Get plugins configured for this hook - hook_refs = self._registry.get_hook_refs_for_hook(hook_type=hook_type) - - # Execute plugins - result = await self._get_executor().execute( - hook_refs, - payload, - global_context, - hook_type, - local_contexts, - violations_as_exceptions, - extensions=extensions, - ) - - return result - - async def invoke_hook_for_plugin( - self, - name: str, - hook_type: str, - payload: Union[PluginPayload, dict[str, Any], str], - context: Union[PluginContext, GlobalContext], - violations_as_exceptions: bool = False, - payload_as_json: bool = False, - ) -> PluginResult: - """Invoke a specific hook for a single named plugin. - - This method allows direct invocation of a particular plugin's hook by name, - bypassing the normal priority-ordered execution. Useful for testing individual - plugins or when specific plugin behavior needs to be triggered independently. - - Args: - name: The name of the plugin to invoke. - hook_type: The type of hook to execute (e.g., "prompt_pre_fetch"). - payload: The plugin payload to be processed by the hook. - context: Plugin execution context (PluginContext) or GlobalContext (will be wrapped). - violations_as_exceptions: Raise violations as exceptions rather than returns. - payload_as_json: payload passed in as json rather than pydantic. - - Returns: - PluginResult with processing status, modified payload, and metadata. - - Raises: - PluginError: If the plugin or hook type cannot be found in the registry. - ValueError: If payload type does not match payload_as_json setting. - - Examples: - >>> manager = PluginManager("plugins/config.yaml") - >>> # In async context: - >>> # await manager.initialize() - >>> # payload = PromptPrehookPayload(name="test", args={}) - >>> # context = PluginContext(global_context=GlobalContext(request_id="123")) - >>> # result = await manager.invoke_hook_for_plugin( - >>> # name="auth_plugin", - >>> # hook_type="prompt_pre_fetch", - >>> # payload=payload, - >>> # context=context - >>> # ) - """ - # Auto-wrap GlobalContext in PluginContext for convenience - if isinstance(context, GlobalContext): - context = PluginContext(global_context=context) - - hook_ref = self._registry.get_plugin_hook_by_name(name, hook_type) - if not hook_ref: - raise PluginError( - error=PluginErrorModel( - message=f"Unable to find {hook_type} for plugin {name}. Make sure the plugin is registered.", - plugin_name=name, - ) - ) - if payload_as_json: - plugin = hook_ref.plugin_ref.plugin - # When payload_as_json=True, payload should be str or dict - if isinstance(payload, (str, dict)): - pydantic_payload = plugin.json_to_payload(hook_type, payload) - return await self._get_executor().execute_plugin( - hook_ref, pydantic_payload, context, violations_as_exceptions - ) - raise ValueError(f"When payload_as_json=True, payload must be str or dict, got {type(payload)}") - # When payload_as_json=False, payload should already be a PluginPayload - if not isinstance(payload, PluginPayload): - raise ValueError(f"When payload_as_json=False, payload must be a PluginPayload, got {type(payload)}") - return await self._get_executor().execute_plugin(hook_ref, payload, context, violations_as_exceptions) - - -class TenantPluginManager(PluginManager): - """PluginManager with per-context configuration overrides. - - Each instance has independent state (Borg pattern is disabled). - Fully compatible with PluginManager API. - - Examples: - >>> from cpex.framework.models import Config - >>> config = Config(plugins=[]) - >>> tpm = TenantPluginManager(config=config) - >>> tpm.initialized - False - """ - - def __init__( # pylint: disable=super-init-not-called - self, - config: Union[str, Config], - timeout: int = DEFAULT_PLUGIN_TIMEOUT, - observability: Optional[ObservabilityProvider] = None, - hook_policies: Optional[dict[str, HookPayloadPolicy]] = None, - default_hook_policy: Optional[str] = None, - ): - """Initialize a TenantPluginManager with independent state. - - Bypasses PluginManager.__init__ entirely — Borg logic doesn't apply here. - Each TenantPluginManager is fully independent. - - Args: - config: Plugin configuration (path or Config object). - timeout: Per-plugin call timeout in seconds. - observability: Optional observability provider. - hook_policies: Optional hook payload policy map. - default_hook_policy: Fallback hook policy when not specified for a hook type. - """ - if isinstance(config, Config): - self._config_path = None - self._config = config - else: - self._config_path = config - self._config = ConfigLoader.load_config(config) - - self._executor = PluginExecutor( - config=self._config, - timeout=timeout, - observability=observability, - hook_policies=hook_policies, - default_hook_policy=default_hook_policy, - ) - self._initialized = False - self._registry = PluginInstanceRegistry() - self._loader = PluginLoader() - self._async_lock: asyncio.Lock | None = None diff --git a/cpex/framework/memory.py b/cpex/framework/memory.py deleted file mode 100644 index ec2dc8d6..00000000 --- a/cpex/framework/memory.py +++ /dev/null @@ -1,666 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/memory.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Memory management utilities for plugin framework. - -This module provides copy-on-write data structures for efficient memory management -in plugin contexts. -""" - -# Standard -import copy -import logging -import weakref -from collections.abc import Mapping -from typing import Any, Iterator, Optional, TypeVar - -# Third-Party -from pydantic import BaseModel, RootModel - -T = TypeVar("T") -logger = logging.getLogger(__name__) - - -class CopyOnWriteDict(dict): - """ - A dictionary subclass that implements copy-on-write behavior. - - Inherits from dict and layers modifications over an original dictionary - without mutating the original. The dict itself stores modifications, while - reads check the modifications first, then fall back to the original. - - This is useful for plugin contexts where you want to isolate modifications - without copying the entire original dictionary upfront. Since it subclasses - dict, it's compatible with type checking and validation frameworks like Pydantic. - - Example: - >>> original = {"a": 1, "b": 2, "c": 3} - >>> cow = CopyOnWriteDict(original) - >>> isinstance(cow, dict) - True - >>> cow["a"] = 10 # Modification stored in dict - >>> cow["d"] = 4 # New key stored in dict - >>> del cow["b"] # Deletion tracked separately - >>> cow["a"] - 10 - >>> "b" in cow - False - >>> original # Original unchanged - {'a': 1, 'b': 2, 'c': 3} - >>> cow.get_modifications() - {'a': 10, 'd': 4} - """ - - def __init__(self, original: dict): - """ - Initialize a copy-on-write dictionary wrapper. - - Args: - original: The original dictionary to wrap. This will not be modified. - """ - # Initialize parent dict without any data - # The parent dict (self via super()) will store modifications only - super().__init__() - self._original = original - self._deleted = set() # Track keys that have been deleted - - def __getitem__(self, key: Any) -> Any: - """ - Get an item from the dictionary. - - Args: - key: The key to look up. - - Returns: - The value associated with the key. - - Raises: - KeyError: If the key is not found or has been deleted. - """ - if key in self._deleted: - raise KeyError(key) - # Check modifications first (via super()), then original - if super().__contains__(key): - return super().__getitem__(key) - if key in self._original: - return self._original[key] - raise KeyError(key) - - def __setitem__(self, key: Any, value: Any) -> None: - """ - Set an item in the dictionary. - - The modification is stored in the wrapper layer, not the original dict. - - Args: - key: The key to set. - value: The value to associate with the key. - """ - super().__setitem__(key, value) # Store in modifications (parent dict) - self._deleted.discard(key) # If we're setting it, it's not deleted - - def __delitem__(self, key: Any) -> None: - """ - Delete an item from the dictionary. - - The key is marked as deleted in the wrapper layer. - - Args: - key: The key to delete. - - Raises: - KeyError: If the key doesn't exist in the dictionary. - """ - if key not in self: - raise KeyError(key) - self._deleted.add(key) - if super().__contains__(key): - super().__delitem__(key) # Remove from modifications if present - - def __contains__(self, key: Any) -> bool: - """ - Check if a key exists in the dictionary. - - Args: - key: The key to check. - - Returns: - True if the key exists and hasn't been deleted, False otherwise. - """ - if key in self._deleted: - return False - return super().__contains__(key) or key in self._original - - def __len__(self) -> int: - """ - Get the number of items in the dictionary. - - Returns: - The count of non-deleted keys. - """ - # Get all keys from both modifications and original, excluding deleted - all_keys = set(super().keys()) | set(self._original.keys()) - return len(all_keys - self._deleted) - - def __iter__(self) -> Iterator: - """ - Iterate over keys in the dictionary. - - Yields keys in insertion order: first keys from the original dict (in their - original order), then new keys from modifications (in their insertion order). - - Yields: - Keys that haven't been deleted. - """ - # First, yield keys from original (in original order) - for key in self._original: - if key not in self._deleted: - yield key - - # Then yield new keys from modifications (not in original) - for key in super().__iter__(): - if key not in self._original and key not in self._deleted: - yield key - - def __repr__(self) -> str: - """ - Get a string representation of the dictionary. - - Returns: - A string representation showing the current state. - """ - return f"CopyOnWriteDict({dict(self.items())})" - - __hash__ = None - - def __eq__(self, other: Any) -> bool: - """ - Compare equality with another mapping. - - Compares the materialized logical mapping (original + modifications - deletions) - rather than the empty base dict storage. - - Args: - other: The object to compare with. - - Returns: - True if other is a Mapping with the same key-value pairs, False otherwise. - Returns NotImplemented for non-Mapping types to allow other.__eq__ to handle it. - """ - if not isinstance(other, Mapping): - return NotImplemented - - # Fast-path: if lengths differ, mappings cannot be equal - if len(self) != len(other): - return False - - # Compare materialized items - return dict(self.items()) == dict(other.items()) - - def __ne__(self, other: Any) -> bool: - """ - Compare inequality with another mapping. - - Args: - other: The object to compare with. - - Returns: - True if not equal, False if equal. - Returns NotImplemented for non-Mapping types. - """ - eq = self.__eq__(other) - if eq is NotImplemented: - return NotImplemented - return not eq - - def get(self, key: Any, default: Optional[Any] = None) -> Any: - """ - Get an item with a default fallback. - - Args: - key: The key to look up. - default: The value to return if the key is not found. - - Returns: - The value associated with the key, or default if not found/deleted. - """ - try: - return self[key] - except KeyError: - return default - - def keys(self): - """ - Get all non-deleted keys. - - Returns: - A generator of keys. - """ - return iter(self) - - def values(self): - """ - Get all values for non-deleted keys. - - Returns: - A generator of values. - """ - return (self[k] for k in self) - - def items(self): - """ - Get all key-value pairs for non-deleted keys. - - Returns: - A generator of (key, value) tuples. - """ - return ((k, self[k]) for k in self) - - def copy(self) -> dict: - """ - Create a regular dictionary with all current key-value pairs. - - Returns: - A new dict containing the current state (original + modifications - deletions). - """ - return dict(self.items()) - - def get_modifications(self) -> dict: - """ - Get only the modifications made to the wrapper. - - This returns only the keys that were added or changed in the modification layer, - not including values from the original dictionary that weren't modified. - - Returns: - A copy of the modifications dictionary. - """ - # The parent dict (super()) contains only modifications - return dict(super().items()) - - def get_deleted(self) -> set: - """ - Get the set of deleted keys. - - Returns: - A copy of the deleted keys set. - """ - return self._deleted.copy() - - def has_modifications(self) -> bool: - """ - Check if any modifications have been made. - - Returns: - True if there are any modifications or deletions, False otherwise. - """ - # Check if parent dict has any entries (modifications) or if anything was deleted - return super().__len__() > 0 or len(self._deleted) > 0 - - def update(self, other=None, **kwargs) -> None: - """ - Update the dictionary with key-value pairs from another mapping or iterable. - - Args: - other: A mapping or iterable of key-value pairs. - **kwargs: Additional key-value pairs to update. - - Examples: - >>> cow = CopyOnWriteDict({"a": 1}) - >>> cow.update({"b": 2, "c": 3}) - >>> cow.update(d=4, e=5) - >>> dict(cow.items()) - {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5} - """ - if other is not None: - if hasattr(other, "items"): - for key, value in other.items(): - self[key] = value - else: - for key, value in other: - self[key] = value - for key, value in kwargs.items(): - self[key] = value - - def pop(self, key: Any, *args) -> Any: - """ - Remove and return the value for a key. - - Args: - key: The key to remove. - *args: Optional default value if key is not found. - - Returns: - The value associated with the key. - - Raises: - KeyError: If key is not found and no default is provided. - TypeError: If more than one default argument is provided. - - Examples: - >>> cow = CopyOnWriteDict({"a": 1, "b": 2}) - >>> cow.pop("a") - 1 - >>> cow.pop("c", "default") - 'default' - """ - if len(args) > 1: - raise TypeError(f"pop() accepts 1 or 2 arguments ({len(args) + 1} given)") - - try: - value = self[key] - del self[key] - return value - except KeyError: - if args: - return args[0] - raise - - def setdefault(self, key: Any, default: Any = None) -> Any: - """ - Get a value, setting it to a default if not present. - - Args: - key: The key to look up. - default: The default value to set if key is not present. - - Returns: - The value associated with the key (existing or newly set). - - Examples: - >>> cow = CopyOnWriteDict({"a": 1}) - >>> cow.setdefault("a", 10) - 1 - >>> cow.setdefault("b", 2) - 2 - >>> cow["b"] - 2 - """ - if key in self: - return self[key] - self[key] = default - return default - - def clear(self) -> None: - """ - Remove all items from the dictionary. - - This marks all keys (from original and modifications) as deleted. - - Examples: - >>> cow = CopyOnWriteDict({"a": 1, "b": 2}) - >>> cow.clear() - >>> len(cow) - 0 - """ - # Mark all current keys as deleted - for key in list(self.keys()): - self._deleted.add(key) - # Clear modifications from parent dict - super().clear() - - -class CopyOnWriteList(list): - """ - A list subclass that implements copy-on-write behavior using lazy-copy strategy. - - Read operations delegate to the original list; on first write, the entire - list is materialized into the parent ``list`` storage. This is O(0) for - read-only access (common case) and O(n) on first write. - - Example: - >>> original = [1, 2, 3] - >>> cow = CopyOnWriteList(original) - >>> isinstance(cow, list) - True - >>> cow[0] - 1 - >>> cow[0] = 10 # triggers materialization - >>> cow[0] - 10 - >>> original # unchanged - [1, 2, 3] - """ - - def __init__(self, original: list): - """Initialize with the original list to wrap.""" - super().__init__() - self._original = original - self._materialized = False - - # -- internal helpers -------------------------------------------------- - - def _materialize(self): - """Copy original data into parent list storage on first write.""" - if not self._materialized: - super().extend(self._original) - self._materialized = True - - def _source(self): - """Return the backing data: parent list if materialized, else original.""" - return super().__iter__() if self._materialized else self._original - - # -- read operations (delegate to original when not materialized) ------ - - def __getitem__(self, index): - """Return item at index from the active backing store.""" - if self._materialized: - return super().__getitem__(index) - return self._original[index] - - def __len__(self): - """Return the length of the active backing store.""" - if self._materialized: - return super().__len__() - return len(self._original) - - def __iter__(self): - """Iterate over the active backing store.""" - if self._materialized: - return super().__iter__() - return iter(self._original) - - def __contains__(self, item): - """Return True if item is in the active backing store.""" - if self._materialized: - return super().__contains__(item) - return item in self._original - - # -- write operations (materialize on first write) --------------------- - - def __setitem__(self, index, value): - """Set item at index, materializing on first write.""" - self._materialize() - super().__setitem__(index, value) - - def __delitem__(self, index): - """Delete item at index, materializing on first write.""" - self._materialize() - super().__delitem__(index) - - def append(self, value): - """Append value, materializing on first write.""" - self._materialize() - super().append(value) - - def extend(self, values): - """Extend with values, materializing on first write.""" - self._materialize() - super().extend(values) - - def insert(self, index, value): - """Insert value at index, materializing on first write.""" - self._materialize() - super().insert(index, value) - - def remove(self, value): - """Remove first occurrence of value, materializing on first write.""" - self._materialize() - super().remove(value) - - def pop(self, index=-1): - """Remove and return item at index, materializing on first write.""" - self._materialize() - return super().pop(index) - - def clear(self): - """Clear all items, materializing on first write.""" - self._materialize() - super().clear() - - def sort(self, *, key=None, reverse=False): - """Sort in place, materializing on first write.""" - self._materialize() - super().sort(key=key, reverse=reverse) - - def reverse(self): - """Reverse in place, materializing on first write.""" - self._materialize() - super().reverse() - - # -- introspection ----------------------------------------------------- - - def has_modifications(self) -> bool: - """Return True if any write operation has been performed.""" - return self._materialized - - def copy(self) -> list: - """Return a plain list snapshot of the current contents.""" - return list(self) - - def __repr__(self) -> str: - """Return a string representation of the list.""" - return f"CopyOnWriteList({list(self)})" - - -def copyonwrite(o: T) -> T: - """ - Returns a copy-on-write wrapper of the original object. - - Args: - o: The object to wrap. Supports dict and list objects. - - Returns: - A copy-on-write wrapper around the object. - - Raises: - TypeError: If the object type is not supported for copy-on-write wrapping. - """ - if isinstance(o, dict): - return CopyOnWriteDict(o) - if isinstance(o, list): - return CopyOnWriteList(o) - raise TypeError(f"No copy-on-write wrapper available for {type(o)}") - - -# --------------------------------------------------------------------------- -# Payload isolation helpers -# --------------------------------------------------------------------------- - -_PRIMITIVE_TYPES = (str, int, float, bool, bytes, type(None)) - - -_memory_logger = logging.getLogger(__name__) - - -def _safe_deepcopy(value: Any) -> Any: - """Deep-copy *value*, falling back to a shared reference on failure. - - For objects that are not (e.g. objects holding locks, sockets, or async state), - a warning is logged and the original value is returned as a shared reference. - CoW isolation still applies to all other fields in the payload. - """ - try: - return copy.deepcopy(value) - except Exception as e: - _memory_logger.warning( - "Cannot deep-copy value of type %s — sharing reference: %s", - type(value).__qualname__, - e, - ) - return value - - -def _wrap_value(value: Any) -> Any: - """Wrap a single value with the appropriate CoW wrapper. - - - dict → CopyOnWriteDict - - list → CopyOnWriteList - - RootModel → reconstruct with wrapped .root - - BaseModel (non-RootModel) → recursively wrap - - Primitives → share as-is - - Other mutable types → copy.deepcopy fallback - """ - # Weak-reference proxies must be checked first — isinstance() with - # other types dereferences the proxy and raises ReferenceError if - # the referent has been garbage-collected. - if isinstance(value, (weakref.ProxyType, weakref.CallableProxyType)): - return value - if isinstance(value, _PRIMITIVE_TYPES): - return value - if isinstance(value, BaseException): - return value - if isinstance(value, RootModel): - root = value.root - if isinstance(root, dict): - wrapped_root = CopyOnWriteDict(root) - elif isinstance(root, list): - wrapped_root = CopyOnWriteList(root) - else: - wrapped_root = _safe_deepcopy(root) - return value.model_construct(root=wrapped_root) - if isinstance(value, BaseModel): - return wrap_payload_for_isolation(value) - if isinstance(value, dict): - return CopyOnWriteDict(value) - if isinstance(value, list): - return CopyOnWriteList(value) - # Other mutable types — attempt deep copy, fall back to shared reference. - return _safe_deepcopy(value) - - -def wrap_payload_for_isolation(payload: BaseModel) -> BaseModel: - """Return a shallow copy of *payload* with mutable nested fields wrapped - in copy-on-write containers. - - This replaces ``model_copy(deep=True)`` / ``copy.deepcopy()`` for Pydantic - payload isolation. Only fields that contain mutable containers (dicts, - lists, BaseModels) are wrapped; primitives are shared as-is. - - Args: - payload: A frozen Pydantic BaseModel (typically a PluginPayload). - - Returns: - A new model instance with mutable fields CoW-wrapped. - """ - # RootModel payloads (e.g. HttpHeaderPayload) — wrap .root directly - if isinstance(payload, RootModel): - root = payload.root - if isinstance(root, dict): - wrapped_root = CopyOnWriteDict(root) - elif isinstance(root, list): - wrapped_root = CopyOnWriteList(root) - else: - wrapped_root = _safe_deepcopy(root) - return payload.model_construct(root=wrapped_root) - - updates = {} - for field_name, field_info in type(payload).model_fields.items(): - value = getattr(payload, field_name, None) - if value is None: - continue - # Weak-reference proxies are passed through as-is (checked before - # _PRIMITIVE_TYPES to avoid dereferencing a dead proxy). - if isinstance(value, (weakref.ProxyType, weakref.CallableProxyType)): - continue - if isinstance(value, _PRIMITIVE_TYPES): - continue - updates[field_name] = _wrap_value(value) - - if not updates: - return payload - - return payload.model_copy(update=updates) diff --git a/cpex/framework/models.py b/cpex/framework/models.py deleted file mode 100644 index e85ffcf3..00000000 --- a/cpex/framework/models.py +++ /dev/null @@ -1,2445 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/models.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Mihai Criveti, Fred Araujo - -Pydantic models for plugins. -This module implements the pydantic models associated with -the base plugin layer including configurations, and contexts. -""" - -# Standard -import asyncio -import contextlib -import logging -import os -import re -import tempfile -from datetime import datetime -from enum import Enum, StrEnum -from pathlib import Path -from typing import Any, Generic, List, Optional, Self, TypeVar, Union - -import orjson -from packaging.version import InvalidVersion, Version - -# Third-Party -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PrivateAttr, - ValidationInfo, - field_serializer, - field_validator, - model_validator, -) - -# First-Party -from cpex.framework.constants import ( - CMD, - CWD, - ENV, - EXTERNAL_PLUGIN_TYPE, - IGNORE_CONFIG_EXTERNAL, - PYTHON_SUFFIX, - SCRIPT, - UDS, - URL, -) -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.settings import ( - get_client_mtls_settings, - get_grpc_client_mtls_settings, - get_grpc_server_settings, - get_mcp_server_settings, - get_transport_settings, -) -from cpex.framework.validators import validate_plugin_url - -T = TypeVar("T") - - -class TransportType(str, Enum): - """Supported transport mechanisms for MCP plugin communication. - - Attributes: - SSE: Server-Sent Events transport. - HTTP: Standard HTTP-based transport. - STDIO: Standard input/output transport. - STREAMABLEHTTP: HTTP transport with streaming. - GRPC: gRPC transport for external plugins. - - Examples: - >>> TransportType.SSE - - >>> TransportType.STDIO.value - 'STDIO' - >>> TransportType('STREAMABLEHTTP') - - """ - - SSE = "SSE" - HTTP = "HTTP" - STDIO = "STDIO" - STREAMABLEHTTP = "STREAMABLEHTTP" - GRPC = "GRPC" - - -class PluginMode(StrEnum): - """Plugin modes of operation. - - Execution order: SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET - - Each mode defines a unique combination of two orthogonal capabilities — - **blocking** (halting the pipeline) and **modifying** (changing the payload): - - +-----------------+-------+--------+------------------+ - | Mode | Block | Modify | Execution | - +-----------------+-------+--------+------------------+ - | SEQUENTIAL | yes | yes | serial, chained | - | TRANSFORM | no | yes | serial, chained | - | AUDIT | no | no | serial | - | CONCURRENT | yes | no | parallel | - | FIRE_AND_FORGET | no | no | background | - +-----------------+-------+--------+------------------+ - - Attributes: - sequential: Serial, chained execution. Can halt the pipeline and modify - payloads. Global state is merged. Use for policy enforcement + - transformation. - transform: Serial, chained execution. Can modify payloads but cannot halt - the pipeline — blocking results are suppressed. Global state is merged. - Use for data transformation pipelines (PII redaction, prompt rewriting). - audit: Serial execution. Cannot halt the pipeline or modify payloads — - violations are logged, modifications are discarded. Use for observation, - logging, and metrics. - concurrent: Parallel execution with fail-fast on first blocking result. - Can halt the pipeline but cannot modify payloads — modifications are - discarded to avoid non-deterministic last-writer-wins races. Use for - independent policy gates. - fire_and_forget: Background execution via ``asyncio.create_task()``. - Cannot halt the pipeline or modify payloads. Receives an isolated - snapshot. Fires after all other phases. Use for telemetry, async - side effects. - disabled: Plugin disabled — skipped entirely. - - Examples: - >>> PluginMode.SEQUENTIAL - - >>> PluginMode.TRANSFORM - - >>> PluginMode.CONCURRENT - - >>> PluginMode.FIRE_AND_FORGET - - >>> PluginMode.AUDIT.value - 'audit' - >>> PluginMode('disabled') - - >>> 'transform' in [m.value for m in PluginMode] - True - """ - - FIRE_AND_FORGET = "fire_and_forget" - CONCURRENT = "concurrent" - SEQUENTIAL = "sequential" - TRANSFORM = "transform" - AUDIT = "audit" - DISABLED = "disabled" - - -class OnError(StrEnum): - """Error handling behavior for plugins, independent of execution mode. - - Attributes: - fail: Pipeline halts, error propagates (default). - ignore: Error logged; pipeline continues. - disable: Error logged; plugin auto-disabled; pipeline continues. - - Examples: - >>> OnError.FAIL - - >>> OnError.IGNORE.value - 'ignore' - >>> OnError('disable') - - """ - - FAIL = "fail" - IGNORE = "ignore" - DISABLE = "disable" - - -class BaseTemplate(BaseModel): - """Base Template.The ToolTemplate, PromptTemplate and ResourceTemplate could be extended using this - - Attributes: - context (Optional[list[str]]): specifies the keys of context to be extracted. The context could be global (shared between the plugins) or - local (shared within the plugin). Example: global.key1. - extensions (Optional[dict[str, Any]]): add custom keys for your specific plugin. Example - 'policy' - key for opa plugin. - - Examples: - >>> base = BaseTemplate(context=["global.key1.key2", "local.key1.key2"]) - >>> base.context - ['global.key1.key2', 'local.key1.key2'] - >>> base = BaseTemplate(context=["global.key1.key2"], extensions={"policy" : "sample policy"}) - >>> base.extensions - {'policy': 'sample policy'} - """ - - context: Optional[list[str]] = None - extensions: Optional[dict[str, Any]] = None - - -class ToolTemplate(BaseTemplate): - """Tool Template. - - Attributes: - tool_name (str): the name of the tool. - fields (Optional[list[str]]): the tool fields that are affected. - result (bool): analyze tool output if true. - - Examples: - >>> tool = ToolTemplate(tool_name="my_tool") - >>> tool.tool_name - 'my_tool' - >>> tool.result - False - >>> tool2 = ToolTemplate(tool_name="analyzer", fields=["input", "params"], result=True) - >>> tool2.fields - ['input', 'params'] - >>> tool2.result - True - """ - - tool_name: str - fields: Optional[list[str]] = None - result: bool = False - - -class PromptTemplate(BaseTemplate): - """Prompt Template. - - Attributes: - prompt_name (str): the name of the prompt. - fields (Optional[list[str]]): the prompt fields that are affected. - result (bool): analyze tool output if true. - - Examples: - >>> prompt = PromptTemplate(prompt_name="greeting") - >>> prompt.prompt_name - 'greeting' - >>> prompt.result - False - >>> prompt2 = PromptTemplate(prompt_name="question", fields=["context"], result=True) - >>> prompt2.fields - ['context'] - """ - - prompt_name: str - fields: Optional[list[str]] = None - result: bool = False - - -class ResourceTemplate(BaseTemplate): - """Resource Template. - - Attributes: - resource_uri (str): the URI of the resource. - fields (Optional[list[str]]): the resource fields that are affected. - result (bool): analyze resource output if true. - - Examples: - >>> resource = ResourceTemplate(resource_uri="file:///data.txt") - >>> resource.resource_uri - 'file:///data.txt' - >>> resource.result - False - >>> resource2 = ResourceTemplate(resource_uri="http://api/data", fields=["content"], result=True) - >>> resource2.fields - ['content'] - """ - - resource_uri: str - fields: Optional[list[str]] = None - result: bool = False - - -class PluginCondition(BaseModel): - """Conditions for when plugin should execute. - - Attributes: - server_ids (Optional[set[str]]): set of server ids. - tenant_ids (Optional[set[str]]): set of tenant ids. - tools (Optional[set[str]]): set of tool names. - prompts (Optional[set[str]]): set of prompt names. - resources (Optional[set[str]]): set of resource URIs. - agents (Optional[set[str]]): set of agent IDs. - user_pattern (Optional[list[str]]): list of user patterns. - content_types (Optional[list[str]]): list of content types. - - Examples: - >>> cond = PluginCondition(server_ids={"server1", "server2"}) - >>> "server1" in cond.server_ids - True - >>> cond2 = PluginCondition(tools={"tool1"}, prompts={"prompt1"}) - >>> cond2.tools - {'tool1'} - >>> cond3 = PluginCondition(user_patterns=["admin", "root"]) - >>> len(cond3.user_patterns) - 2 - """ - - server_ids: Optional[set[str]] = None - tenant_ids: Optional[set[str]] = None - tools: Optional[set[str]] = None - prompts: Optional[set[str]] = None - resources: Optional[set[str]] = None - agents: Optional[set[str]] = None - user_patterns: Optional[list[str]] = None - content_types: Optional[list[str]] = None - - @field_validator("content_types") - @classmethod - def normalize_content_types(cls, value: list[str] | None) -> list[str] | None: - """Pre-normalize content types during initialization. - - Uses the same normalization as utils.normalize_content_type() — strip - parameters after ';', trim whitespace, lowercase. Kept inline here to - avoid a circular import (utils imports models). - - Args: - value: List of content types to normalize. - - Returns: - Normalized list of content types (base type without parameters). - """ - if value: - return [ct.split(";", maxsplit=1)[0].strip().lower() for ct in value] - return value - - @field_serializer("server_ids", "tenant_ids", "tools", "prompts", "resources", "agents") - def serialize_set(self, value: set[str] | None) -> list[str] | None: - """Serialize set objects in PluginCondition for MCP. - - Args: - value: a set of server ids, tenant ids, tools or prompts. - - Returns: - The set as a serializable list. - """ - if value: - values = [] - for key in value: - values.append(key) - return values - return None - - -class AppliedTo(BaseModel): - """What tools/prompts/resources and fields the plugin will be applied to. - - Attributes: - tools (Optional[list[ToolTemplate]]): tools and fields to be applied. - prompts (Optional[list[PromptTemplate]]): prompts and fields to be applied. - resources (Optional[list[ResourceTemplate]]): resources and fields to be applied. - global_context (Optional[list[str]]): keys in the context to be applied on globally - local_context(Optional[list[str]]): keys in the context to be applied on locally - """ - - tools: Optional[list[ToolTemplate]] = None - prompts: Optional[list[PromptTemplate]] = None - resources: Optional[list[ResourceTemplate]] = None - - -class MCPTransportTLSConfigBase(BaseModel): - """Base TLS configuration with common fields for both client and server. - - Attributes: - certfile (Optional[str]): Path to the PEM-encoded certificate file. - keyfile (Optional[str]): Path to the PEM-encoded private key file. - ca_bundle (Optional[str]): Path to a CA bundle file for verification. - keyfile_password (Optional[str]): Optional password for encrypted private key. - """ - - certfile: Optional[str] = Field(default=None, description="Path to PEM certificate file") - keyfile: Optional[str] = Field(default=None, description="Path to PEM private key file") - ca_bundle: Optional[str] = Field(default=None, description="Path to CA bundle for verification") - keyfile_password: Optional[str] = Field(default=None, description="Password for encrypted private key") - - @field_validator("ca_bundle", "certfile", "keyfile", mode="after") - @classmethod - def validate_path(cls, value: Optional[str]) -> Optional[str]: - """Expand and validate file paths supplied in TLS configuration. - - Args: - value: File path to validate. - - Returns: - Expanded file path or None if not provided. - - Raises: - ValueError: If file path does not exist. - """ - - if not value: - return value - expanded = Path(value).expanduser() - if not expanded.is_file(): - raise ValueError(f"TLS file path does not exist: {value}") - return str(expanded) - - @model_validator(mode="after") - def validate_cert_key(self) -> Self: # pylint: disable=bad-classmethod-argument - """Ensure certificate and key options are consistent. - - Returns: - Self after validation. - - Raises: - ValueError: If keyfile is specified without certfile. - """ - - if self.keyfile and not self.certfile: - raise ValueError("keyfile requires certfile to be specified") - return self - - -class MCPClientTLSConfig(MCPTransportTLSConfigBase): - """Client-side TLS configuration (gateway connecting to plugin). - - Attributes: - verify (bool): Whether to verify the remote server certificate. - check_hostname (bool): Enable hostname verification when verify is true. - """ - - verify: bool = Field(default=True, description="Verify the upstream server certificate") - check_hostname: bool = Field(default=True, description="Enable hostname verification") - - @classmethod - def from_env(cls) -> Optional["MCPClientTLSConfig"]: - """Construct client TLS configuration from PLUGINS_CLIENT_* environment variables. - - Returns: - MCPClientTLSConfig instance or None if no environment variables are set. - """ - s = get_client_mtls_settings() - data: dict[str, Any] = {} - - if s.client_mtls_certfile: - data["certfile"] = s.client_mtls_certfile - if s.client_mtls_keyfile: - data["keyfile"] = s.client_mtls_keyfile - if s.client_mtls_ca_bundle: - data["ca_bundle"] = s.client_mtls_ca_bundle - if s.client_mtls_keyfile_password is not None: - data["keyfile_password"] = s.client_mtls_keyfile_password.get_secret_value() - if s.client_mtls_verify is not None: - data["verify"] = s.client_mtls_verify - if s.client_mtls_check_hostname is not None: - data["check_hostname"] = s.client_mtls_check_hostname - - if not data: - return None - - return cls(**data) - - -class MCPServerTLSConfig(MCPTransportTLSConfigBase): - """Server-side TLS configuration (plugin accepting gateway connections). - - Attributes: - ssl_cert_reqs (int): Client certificate requirement (0=NONE, 1=OPTIONAL, 2=REQUIRED). - """ - - ssl_cert_reqs: int = Field(default=2, description="Client certificate requirement (0=NONE, 1=OPTIONAL, 2=REQUIRED)") - - @classmethod - def from_env(cls) -> Optional["MCPServerTLSConfig"]: - """Construct server TLS configuration from PLUGINS_SERVER_SSL_* environment variables. - - Returns: - MCPServerTLSConfig instance or None if no environment variables are set. - """ - s = get_mcp_server_settings() - data: dict[str, Any] = {} - - if s.server_ssl_keyfile: - data["keyfile"] = s.server_ssl_keyfile - if s.server_ssl_certfile: - data["certfile"] = s.server_ssl_certfile - if s.server_ssl_ca_certs: - data["ca_bundle"] = s.server_ssl_ca_certs - if s.server_ssl_keyfile_password is not None: - data["keyfile_password"] = s.server_ssl_keyfile_password.get_secret_value() - if s.server_ssl_cert_reqs is not None: - data["ssl_cert_reqs"] = s.server_ssl_cert_reqs - - if not data: - return None - - return cls(**data) - - -class MCPServerConfig(BaseModel): - """Server-side MCP configuration (plugin running as server). - - Attributes: - host (str): Server host to bind to. - port (int): Server port to bind to. - uds (Optional[str]): Unix domain socket path for streamable HTTP. - tls (Optional[MCPServerTLSConfig]): Server-side TLS configuration. - """ - - host: str = Field(default="127.0.0.1", description="Server host to bind to") - port: int = Field(default=8000, description="Server port to bind to") - uds: Optional[str] = Field(default=None, description="Unix domain socket path for streamable HTTP") - tls: Optional[MCPServerTLSConfig] = Field(default=None, description="Server-side TLS configuration") - - @field_validator("uds", mode="after") - @classmethod - def validate_uds(cls, uds: str | None) -> str | None: - """Validate the Unix domain socket path for security. - - Args: - uds: Unix domain socket path. - - Returns: - The validated canonical uds path or None if none is set. - - Raises: - ValueError: if uds is empty, not absolute, or parent directory is invalid. - """ - if uds is None: - return uds - if not isinstance(uds, str) or not uds.strip(): - raise ValueError("MCP server uds must be a non-empty string.") - - uds_path = Path(uds).expanduser().resolve() - if not uds_path.is_absolute(): - raise ValueError(f"MCP server uds path must be absolute: {uds}") - - parent_dir = uds_path.parent - if not parent_dir.is_dir(): - raise ValueError(f"MCP server uds parent directory does not exist: {parent_dir}") - - # Check parent directory permissions for security - try: - parent_mode = parent_dir.stat().st_mode - # Warn if parent directory is world-writable (o+w = 0o002) - if parent_mode & 0o002: - logging.getLogger(__name__).warning( - "MCP server uds parent directory %s is world-writable. This may allow unauthorized socket hijacking. Consider using a directory with restricted permissions (e.g., 0o700).", - parent_dir, - ) - except OSError: - pass # Best effort - continue if we can't check permissions - - return str(uds_path) - - @model_validator(mode="after") - def validate_uds_tls(self) -> Self: # pylint: disable=bad-classmethod-argument - """Ensure TLS is not configured when using a Unix domain socket. - - Returns: - Self after validation. - - Raises: - ValueError: if tls is set with uds. - """ - if self.uds and self.tls: - raise ValueError("TLS configuration is not supported for Unix domain sockets.") - return self - - @classmethod - def from_env(cls) -> Optional["MCPServerConfig"]: - """Construct server configuration from PLUGINS_SERVER_* environment variables. - - Returns: - MCPServerConfig instance or None if no environment variables are set. - """ - s = get_mcp_server_settings() - data: dict[str, Any] = {} - - if s.server_host: - data["host"] = s.server_host - if s.server_port is not None: - data["port"] = s.server_port - if s.server_uds: - data["uds"] = s.server_uds - - # Check if SSL/TLS is enabled - if s.server_ssl_enabled: - tls_config = MCPServerTLSConfig.from_env() - if tls_config: - data["tls"] = tls_config - - if not data: - return None - - return cls(**data) - - -class MCPClientConfig(BaseModel): - """Client-side MCP configuration (gateway connecting to external plugin). - - Attributes: - proto (TransportType): The MCP transport type. Can be SSE, STDIO, or STREAMABLEHTTP - url (Optional[str]): An MCP URL. Only valid when MCP transport type is SSE or STREAMABLEHTTP. - script (Optional[str]): The path and name to the STDIO script that runs the plugin server. Only valid for STDIO type. - cmd (Optional[list[str]]): Command + args used to start a STDIO MCP server. Only valid for STDIO type. - env (Optional[dict[str, str]]): Environment overrides for STDIO server process. - cwd (Optional[str]): Working directory for STDIO server process. - uds (Optional[str]): Unix domain socket path for streamable HTTP. - tls (Optional[MCPClientTLSConfig]): Client-side TLS configuration for mTLS. - reconnect_attempts (int): Number of reconnection attempts on session failure. - reconnect_delay (float): Base delay between reconnection attempts (seconds, linear backoff). - """ - - proto: TransportType - url: Optional[str] = None - script: Optional[str] = None - cmd: Optional[list[str]] = None - env: Optional[dict[str, str]] = None - cwd: Optional[str] = None - uds: Optional[str] = None - tls: Optional[MCPClientTLSConfig] = None - reconnect_attempts: int = Field(default=3, description="Number of reconnection attempts on session failure") - reconnect_delay: float = Field(default=0.1, description="Base delay between reconnection attempts (seconds)") - - @field_validator(URL, mode="after") - @classmethod - def validate_url(cls, url: str | None) -> str | None: - """Validate a MCP url for streamable HTTP connections. - - Args: - url: the url to be validated. - - Raises: - ValueError: if the URL fails validation. - - Returns: - The validated URL or None if none is set. - """ - if url: - result = validate_plugin_url(url) - return result - return url - - @field_validator(SCRIPT, mode="after") - @classmethod - def validate_script(cls, script: str | None) -> str | None: - """Validate an MCP stdio script. - - Args: - script: the script to be validated. - - Raises: - ValueError: if the script doesn't exist or isn't executable when required. - - Returns: - The validated string or None if none is set. - """ - if script: - file_path = Path(script).expanduser() - # Allow relative paths; they are resolved at runtime (optionally using cwd). - if file_path.is_absolute(): - if not file_path.is_file(): - raise ValueError(f"MCP server script {script} does not exist.") - # Allow Python (.py) and shell scripts (.sh). Other files must be executable. - if file_path.suffix not in {PYTHON_SUFFIX, ".sh"} and not os.access(file_path, os.X_OK): - raise ValueError(f"MCP server script {script} must be executable.") - return script - - @field_validator(CMD, mode="after") - @classmethod - def validate_cmd(cls, cmd: list[str] | None) -> list[str] | None: - """Validate an MCP stdio command. - - Args: - cmd: the command to be validated. - - Raises: - ValueError: if cmd is empty or contains empty values. - - Returns: - The validated command list or None if none is set. - """ - if cmd is None: - return cmd - if not isinstance(cmd, list) or not cmd: - raise ValueError("MCP stdio cmd must be a non-empty list.") - if not all(isinstance(part, str) and part.strip() for part in cmd): - raise ValueError("MCP stdio cmd entries must be non-empty strings.") - return cmd - - @field_validator(ENV, mode="after") - @classmethod - def validate_env(cls, env: dict[str, str] | None) -> dict[str, str] | None: - """Validate environment overrides for MCP stdio. - - Args: - env: Environment overrides to set for the stdio plugin process. - - Returns: - The validated environment dict or None if none is set. - - Raises: - ValueError: if keys/values are invalid or the dict is empty. - """ - if env is None: - return env - if not isinstance(env, dict) or not env: - raise ValueError("MCP stdio env must be a non-empty dict.") - for key, value in env.items(): - if not isinstance(key, str) or not key.strip(): - raise ValueError("MCP stdio env keys must be non-empty strings.") - if not isinstance(value, str): - raise ValueError("MCP stdio env values must be strings.") - return env - - @field_validator(CWD, mode="after") - @classmethod - def validate_cwd(cls, cwd: str | None) -> str | None: - """Validate the working directory for MCP stdio. - - Args: - cwd: Working directory for the stdio plugin process. - - Returns: - The validated canonical cwd path or None if none is set. - - Raises: - ValueError: if cwd does not exist or is not a directory. - """ - if not cwd: - return cwd - cwd_path = Path(cwd).expanduser().resolve() - if not cwd_path.is_dir(): - raise ValueError(f"MCP stdio cwd {cwd} does not exist or is not a directory.") - return str(cwd_path) - - @field_validator(UDS, mode="after") - @classmethod - def validate_uds(cls, uds: str | None) -> str | None: - """Validate a Unix domain socket path for streamable HTTP. - - Args: - uds: Unix domain socket path. - - Returns: - The validated canonical uds path or None if none is set. - - Raises: - ValueError: if uds is empty, not absolute, or parent directory is invalid. - """ - if uds is None: - return uds - if not isinstance(uds, str) or not uds.strip(): - raise ValueError("MCP client uds must be a non-empty string.") - - uds_path = Path(uds).expanduser().resolve() - if not uds_path.is_absolute(): - raise ValueError(f"MCP client uds path must be absolute: {uds}") - - parent_dir = uds_path.parent - if not parent_dir.is_dir(): - raise ValueError(f"MCP client uds parent directory does not exist: {parent_dir}") - - # Check parent directory permissions for security - try: - parent_mode = parent_dir.stat().st_mode - # Warn if parent directory is world-writable (o+w = 0o002) - if parent_mode & 0o002: - logging.getLogger(__name__).warning( - "MCP client uds parent directory %s is world-writable. This may allow unauthorized socket hijacking. Consider using a directory with restricted permissions (e.g., 0o700).", - parent_dir, - ) - except OSError: - pass # Best effort - continue if we can't check permissions - - return str(uds_path) - - @model_validator(mode="after") - def validate_tls_usage(self) -> Self: # pylint: disable=bad-classmethod-argument - """Ensure TLS configuration is only used with HTTP-based transports. - - Returns: - Self after validation. - - Raises: - ValueError: If TLS configuration is used with non-HTTP transports. - """ - - if self.tls and self.proto not in (TransportType.SSE, TransportType.STREAMABLEHTTP): - raise ValueError("TLS configuration is only valid for HTTP/SSE transports") - if self.uds and self.tls: - raise ValueError("TLS configuration is not supported for Unix domain sockets.") - return self - - @model_validator(mode="after") - def validate_transport_fields(self) -> Self: # pylint: disable=bad-classmethod-argument - """Ensure transport-specific fields are only used with matching transports. - - Returns: - Self after validation. - - Raises: - ValueError: if fields are incompatible with the selected transport. - """ - if self.proto == TransportType.STDIO and self.url: - raise ValueError("URL is only valid for HTTP/SSE transports") - if self.proto != TransportType.STDIO and (self.script or self.cmd or self.env or self.cwd): - raise ValueError("script/cmd/env/cwd are only valid for STDIO transport") - if self.proto != TransportType.STREAMABLEHTTP and self.uds: - raise ValueError("uds is only valid for STREAMABLEHTTP transport") - return self - - -class GRPCClientTLSConfig(MCPTransportTLSConfigBase): - """Client-side gRPC TLS configuration (gateway connecting to plugin). - - Attributes: - verify (bool): Whether to verify the remote server certificate. - """ - - verify: bool = Field(default=True, description="Verify the upstream server certificate") - - @classmethod - def from_env(cls) -> Optional["GRPCClientTLSConfig"]: - """Construct gRPC client TLS configuration from PLUGINS_GRPC_CLIENT_* environment variables. - - Returns: - GRPCClientTLSConfig instance or None if no environment variables are set. - """ - s = get_grpc_client_mtls_settings() - data: dict[str, Any] = {} - - if s.grpc_client_mtls_certfile: - data["certfile"] = s.grpc_client_mtls_certfile - if s.grpc_client_mtls_keyfile: - data["keyfile"] = s.grpc_client_mtls_keyfile - if s.grpc_client_mtls_ca_bundle: - data["ca_bundle"] = s.grpc_client_mtls_ca_bundle - if s.grpc_client_mtls_keyfile_password is not None: - data["keyfile_password"] = s.grpc_client_mtls_keyfile_password.get_secret_value() - if s.grpc_client_mtls_verify is not None: - data["verify"] = s.grpc_client_mtls_verify - - if not data: - return None - - return cls(**data) - - -class GRPCServerTLSConfig(MCPTransportTLSConfigBase): - """Server-side gRPC TLS configuration (plugin accepting gateway connections). - - Attributes: - client_auth (str): Client certificate requirement ('none', 'optional', 'require'). - """ - - client_auth: str = Field(default="require", description="Client certificate requirement (none, optional, require)") - - @field_validator("client_auth", mode="after") - @classmethod - def validate_client_auth(cls, value: str) -> str: - """Validate client_auth value. - - Args: - value: Client auth requirement string. - - Returns: - Validated client auth string. - - Raises: - ValueError: If client_auth is not a valid value. - """ - valid_values = {"none", "optional", "require"} - if value.lower() not in valid_values: - raise ValueError(f"client_auth must be one of {valid_values}, got '{value}'") - return value.lower() - - @classmethod - def from_env(cls) -> Optional["GRPCServerTLSConfig"]: - """Construct gRPC server TLS configuration from PLUGINS_GRPC_SERVER_SSL_* environment variables. - - Returns: - GRPCServerTLSConfig instance or None if no environment variables are set. - """ - s = get_grpc_server_settings() - data: dict[str, Any] = {} - - if s.grpc_server_ssl_keyfile: - data["keyfile"] = s.grpc_server_ssl_keyfile - if s.grpc_server_ssl_certfile: - data["certfile"] = s.grpc_server_ssl_certfile - if s.grpc_server_ssl_ca_certs: - data["ca_bundle"] = s.grpc_server_ssl_ca_certs - if s.grpc_server_ssl_keyfile_password is not None: - data["keyfile_password"] = s.grpc_server_ssl_keyfile_password.get_secret_value() - if s.grpc_server_ssl_client_auth: - data["client_auth"] = s.grpc_server_ssl_client_auth - - if not data: - return None - - return cls(**data) - - -class GRPCClientConfig(BaseModel): - """Client-side gRPC configuration (gateway connecting to external plugin). - - Attributes: - target (Optional[str]): The gRPC target address in host:port format. - uds (Optional[str]): Unix domain socket path (alternative to target). - tls (Optional[GRPCClientTLSConfig]): Client-side TLS configuration for mTLS. - - Examples: - >>> # TCP connection - >>> config = GRPCClientConfig(target="localhost:50051") - >>> config.get_target() - 'localhost:50051' - >>> # Unix domain socket connection (path is resolved to canonical form) - >>> config = GRPCClientConfig(uds="/tmp/grpc-plugin.sock") # doctest: +SKIP - >>> config.get_target() # doctest: +SKIP - 'unix:///tmp/grpc-plugin.sock' - """ - - target: Optional[str] = Field(default=None, description="gRPC target address (host:port)") - uds: Optional[str] = Field(default=None, description="Unix domain socket path") - tls: Optional[GRPCClientTLSConfig] = None - - @field_validator("target", mode="after") - @classmethod - def validate_target(cls, target: str | None) -> str | None: - """Validate gRPC target address format. - - Args: - target: The target address to validate. - - Returns: - The validated target address. - - Raises: - ValueError: If target is not in host:port format. - """ - if target is None: - return target - if not target: - raise ValueError("gRPC target address cannot be empty") - # Basic validation - should contain host and port - if ":" not in target: - raise ValueError(f"gRPC target must be in host:port format, got '{target}'") - return target - - @field_validator("uds", mode="after") - @classmethod - def validate_uds(cls, uds: str | None) -> str | None: - """Validate Unix domain socket path for gRPC. - - Args: - uds: Unix domain socket path. - - Returns: - The validated canonical uds path or None if none is set. - - Raises: - ValueError: if uds is empty, not absolute, or parent directory is invalid. - """ - if uds is None: - return uds - if not isinstance(uds, str) or not uds.strip(): - raise ValueError("gRPC client uds must be a non-empty string.") - - uds_path = Path(uds).expanduser().resolve() - if not uds_path.is_absolute(): - raise ValueError(f"gRPC client uds path must be absolute: {uds}") - - parent_dir = uds_path.parent - if not parent_dir.is_dir(): - raise ValueError(f"gRPC client uds parent directory does not exist: {parent_dir}") - - # Check parent directory permissions for security - try: - parent_mode = parent_dir.stat().st_mode - if parent_mode & 0o002: - logging.getLogger(__name__).warning( - "gRPC client uds parent directory %s is world-writable. Consider using a directory with restricted permissions.", - parent_dir, - ) - except OSError: - pass - - return str(uds_path) - - @model_validator(mode="after") - def validate_target_or_uds(self) -> Self: # pylint: disable=bad-classmethod-argument - """Ensure exactly one of target or uds is configured. - - Returns: - Self after validation. - - Raises: - ValueError: If neither or both target and uds are set. - """ - has_target = self.target is not None - has_uds = self.uds is not None - - if not has_target and not has_uds: - raise ValueError("gRPC client must have either 'target' or 'uds' configured") - if has_target and has_uds: - raise ValueError("gRPC client cannot have both 'target' and 'uds' configured") - if has_uds and self.tls: - raise ValueError("TLS configuration is not supported for Unix domain sockets") - return self - - def get_target(self) -> str: - """Get the gRPC target string for channel creation. - - Returns: - str: The target string, either host:port or unix:///path format. - """ - if self.uds: - return f"unix://{self.uds}" - return self.target or "" - - -class GRPCServerConfig(BaseModel): - """Server-side gRPC configuration (plugin running as gRPC server). - - Attributes: - host (str): Server host to bind to. - port (int): Server port to bind to. - uds (Optional[str]): Unix domain socket path (alternative to host:port). - tls (Optional[GRPCServerTLSConfig]): Server-side TLS configuration. - - Examples: - >>> # TCP binding - >>> config = GRPCServerConfig(host="0.0.0.0", port=50051) - >>> config.get_bind_address() - '0.0.0.0:50051' - >>> # Unix domain socket binding (path is resolved to canonical form) - >>> config = GRPCServerConfig(uds="/tmp/grpc-plugin.sock") # doctest: +SKIP - >>> config.get_bind_address() # doctest: +SKIP - 'unix:///tmp/grpc-plugin.sock' - """ - - host: str = Field(default="127.0.0.1", description="Server host to bind to") - port: int = Field(default=50051, description="Server port to bind to") - uds: Optional[str] = Field(default=None, description="Unix domain socket path") - tls: Optional[GRPCServerTLSConfig] = Field(default=None, description="Server-side TLS configuration") - - @field_validator("uds", mode="after") - @classmethod - def validate_uds(cls, uds: str | None) -> str | None: - """Validate Unix domain socket path for gRPC server. - - Args: - uds: Unix domain socket path. - - Returns: - The validated canonical uds path or None if none is set. - - Raises: - ValueError: if uds is empty, not absolute, or parent directory is invalid. - """ - if uds is None: - return uds - if not isinstance(uds, str) or not uds.strip(): - raise ValueError("gRPC server uds must be a non-empty string.") - - uds_path = Path(uds).expanduser().resolve() - if not uds_path.is_absolute(): - raise ValueError(f"gRPC server uds path must be absolute: {uds}") - - parent_dir = uds_path.parent - if not parent_dir.is_dir(): - raise ValueError(f"gRPC server uds parent directory does not exist: {parent_dir}") - - # Check parent directory permissions for security - try: - parent_mode = parent_dir.stat().st_mode - if parent_mode & 0o002: - logging.getLogger(__name__).warning( - "gRPC server uds parent directory %s is world-writable. Consider using a directory with restricted permissions.", - parent_dir, - ) - except OSError: - pass - - return str(uds_path) - - @model_validator(mode="after") - def validate_uds_tls(self) -> Self: # pylint: disable=bad-classmethod-argument - """Ensure TLS is not configured when using a Unix domain socket. - - Returns: - Self after validation. - - Raises: - ValueError: if tls is set with uds. - """ - if self.uds and self.tls: - raise ValueError("TLS configuration is not supported for Unix domain sockets") - return self - - def get_bind_address(self) -> str: - """Get the gRPC bind address string. - - Returns: - str: The bind address, either host:port or unix:///path format. - """ - if self.uds: - return f"unix://{self.uds}" - return f"{self.host}:{self.port}" - - @classmethod - def from_env(cls) -> Optional["GRPCServerConfig"]: - """Construct gRPC server configuration from PLUGINS_GRPC_SERVER_* environment variables. - - Returns: - GRPCServerConfig instance or None if no environment variables are set. - """ - s = get_grpc_server_settings() - data: dict[str, Any] = {} - - if s.grpc_server_host: - data["host"] = s.grpc_server_host - if s.grpc_server_port is not None: - data["port"] = s.grpc_server_port - if s.grpc_server_uds: - data["uds"] = s.grpc_server_uds - - # Check if SSL/TLS is enabled - if s.grpc_server_ssl_enabled: - tls_config = GRPCServerTLSConfig.from_env() - if tls_config: - data["tls"] = tls_config - - if not data: - return None - - return cls(**data) - - -class UnixSocketClientConfig(BaseModel): - """Client-side Unix socket configuration (gateway connecting to external plugin). - - Attributes: - path (str): Path to the Unix domain socket file. - reconnect_attempts (int): Number of reconnection attempts on failure. - reconnect_delay (float): Base delay between reconnection attempts (with exponential backoff). - timeout (float): Timeout for read operations in seconds. - - Examples: - >>> config = UnixSocketClientConfig(path="/tmp/plugin.sock") - >>> config.path - '/tmp/plugin.sock' - >>> config.reconnect_attempts - 3 - """ - - path: str = Field(..., description="Path to the Unix domain socket") - reconnect_attempts: int = Field(default=3, description="Number of reconnection attempts") - reconnect_delay: float = Field(default=0.1, description="Base delay between reconnection attempts (seconds)") - timeout: float = Field(default=30.0, description="Read timeout in seconds") - - @field_validator("path", mode="after") - @classmethod - def validate_path(cls, path: str) -> str: - """Validate Unix socket path. - - Args: - path: The socket path to validate. - - Returns: - The validated path. - - Raises: - ValueError: If path is empty or invalid. - """ - if not path: - raise ValueError("Unix socket path cannot be empty") - if not path.startswith("/"): - raise ValueError(f"Unix socket path must be absolute, got '{path}'") - return path - - -class UnixSocketServerConfig(BaseModel): - """Server-side Unix socket configuration (plugin running as Unix socket server). - - Attributes: - path (str): Path to the Unix domain socket file. - - Examples: - >>> config = UnixSocketServerConfig(path="/tmp/plugin.sock") - >>> config.path - '/tmp/plugin.sock' - """ - - path: str = Field(default="/tmp/mcpgateway-plugins.sock", description="Path to the Unix domain socket") # nosec B108 - configurable default - - @classmethod - def from_env(cls) -> Optional["UnixSocketServerConfig"]: - """Construct Unix socket server configuration from environment variables. - - Returns: - UnixSocketServerConfig instance or None if no environment variables are set. - """ - s = get_transport_settings() - data: dict[str, Any] = {} - - if s.unix_socket_path: - data["path"] = s.unix_socket_path - - if not data: - return None - - return cls(**data) - - -class PluginConfig(BaseModel): - """A plugin configuration. - - Attributes: - name (str): The unique name of the plugin. - description (str): A description of the plugin. - author (str): The author of the plugin. - kind (str): The kind or type of plugin. Usually a fully qualified object type. - namespace (str): The namespace where the plugin resides. - version (str): version of the plugin. - hooks (list[str]): a list of the hook points where the plugin will be called. Default: []. - tags (list[str]): a list of tags for making the plugin searchable. - mode (PluginMode): the execution mode of the plugin. Default: CONCURRENT. - on_error (OnError): error handling behavior independent of mode. Default: FAIL. - priority (int): indicates the order in which the plugin is run. Lower = higher priority. Default: 100. - conditions (Optional[list[PluginCondition]]): the conditions on which the plugin is run. - applied_to (Optional[list[AppliedTo]]): the tools, fields, that the plugin is applied to. - config (dict[str, Any]): the plugin specific configurations. - mcp (Optional[MCPClientConfig]): Client-side MCP configuration (gateway connecting to plugin). - grpc (Optional[GRPCClientConfig]): Client-side gRPC configuration (gateway connecting to plugin). - max_content_size (Optional(int)): The maximum size of payload, context, - """ - - model_config = ConfigDict(frozen=True) - - name: str - description: Optional[str] = None - author: Optional[str] = None - kind: str - namespace: Optional[str] = None - version: Optional[str] = None - hooks: list[str] = Field(default_factory=list) - tags: list[str] = Field(default_factory=list) - mode: PluginMode = PluginMode.SEQUENTIAL - on_error: OnError = OnError.FAIL - priority: int = 100 # Lower = higher priority - max_content_size: int = 10000000 - - @model_validator(mode="before") - @classmethod - def _migrate_legacy_modes(cls, data: Any) -> Any: - """Migrate legacy mode values to current canonical mode names. - - Migrations: - - ``enforce_ignore_error`` → ``sequential`` + ``on_error=ignore`` - - ``enforce`` → ``sequential`` - - ``permissive`` → ``transform`` - """ - if isinstance(data, dict): - mode = data.get("mode") - if mode == "enforce_ignore_error": - data["mode"] = "sequential" - data.setdefault("on_error", "ignore") - elif mode == "enforce": - data["mode"] = "sequential" - elif mode == "permissive": - data["mode"] = "transform" - return data - - conditions: list[PluginCondition] = Field(default_factory=list) # When to apply - applied_to: Optional[AppliedTo] = None # Fields to apply to. - capabilities: frozenset[str] = Field( - default_factory=frozenset, - description="Declared capabilities (e.g., 'read_headers', 'append_labels').", - ) - config: Optional[dict[str, Any]] = None - mcp: Optional[MCPClientConfig] = None - grpc: Optional[GRPCClientConfig] = None - unix_socket: Optional[UnixSocketClientConfig] = None - - @field_validator("capabilities", mode="before") - @classmethod - def _validate_capabilities(cls, v: Any) -> frozenset[str]: - """Validate that all declared capabilities are known. - - Args: - v: Raw capabilities value from the config. - - Returns: - A validated frozenset of capability strings. - - Raises: - ValueError: If an unknown capability is declared. - """ - # First-Party - from cpex.framework.extensions.tiers import Capability # pylint: disable=import-outside-toplevel - - if isinstance(v, (list, set, frozenset)): - known = {c.value for c in Capability} - for cap in v: - if cap not in known: - raise ValueError(f"Unknown capability: {cap!r}. Known: {sorted(known)}") - return frozenset(v) - return frozenset() - - @field_serializer("capabilities") - def serialize_capabilities(self, value: frozenset[str]) -> list[str]: - """Serialize frozenset for JSON compatibility.""" - return sorted(value) - - @model_validator(mode="after") - def check_url_or_script_filled(self) -> Self: # pylint: disable=bad-classmethod-argument - """Checks to see that at least one of url or script are set depending on MCP server configuration. - - Raises: - ValueError: if the script/cmd attribute is not defined with STDIO set, or the URL not defined with HTTP transports. - - Returns: - The model after validation. - """ - if not self.mcp: - return self - if self.mcp.proto == TransportType.STDIO and not (self.mcp.script or self.mcp.cmd): - raise ValueError(f"Plugin {self.name} has transport type set to STDIO but no script/cmd value") - if self.mcp.proto == TransportType.STDIO and self.mcp.script and self.mcp.cmd: - raise ValueError(f"Plugin {self.name} must set either script or cmd for STDIO, not both") - if self.mcp.proto in (TransportType.STREAMABLEHTTP, TransportType.SSE) and not self.mcp.url: - raise ValueError(f"Plugin {self.name} has transport type set to StreamableHTTP but no url value") - if self.mcp.proto not in (TransportType.SSE, TransportType.STREAMABLEHTTP, TransportType.STDIO): - raise ValueError(f"Plugin {self.name} must set transport type to either SSE or STREAMABLEHTTP or STDIO") - return self - - @model_validator(mode="after") - def check_config_and_external(self, info: ValidationInfo) -> Self: # pylint: disable=bad-classmethod-argument - """Checks to see that a plugin's 'config' section is not defined if the kind is 'external'. This is because developers cannot override items in the plugin config section for external plugins. - - Args: - info: the contextual information passed into the pydantic model during model validation. Used to determine validation sequence. - - Raises: - ValueError: if the script attribute is not defined with STDIO set, or the URL not defined with HTTP transports. - - Returns: - The model after validation. - """ - ignore_config_external = False - if info and info.context and IGNORE_CONFIG_EXTERNAL in info.context: - ignore_config_external = info.context[IGNORE_CONFIG_EXTERNAL] - - if not ignore_config_external and self.config and self.kind == EXTERNAL_PLUGIN_TYPE: - raise ValueError( - f"""Cannot have {self.name} plugin defined as 'external' with 'config' set.""" - """ 'config' section settings can only be set on the plugin server.""" - ) - - # External plugins must have exactly one transport configured (mcp, grpc, or unix_socket) - if self.kind == EXTERNAL_PLUGIN_TYPE: - has_mcp = self.mcp is not None - has_grpc = self.grpc is not None - has_unix = self.unix_socket is not None - transport_count = sum([has_mcp, has_grpc, has_unix]) - - if transport_count == 0: - raise ValueError( - f"External plugin {self.name} must have 'mcp', 'grpc', or 'unix_socket' section configured" - ) - if transport_count > 1: - raise ValueError( - f"External plugin {self.name} can only have one transport configured (mcp, grpc, or unix_socket)" - ) - - return self - - def get_safe_config(self) -> str: - """Return a new PluginConfig instance without validator methods. - - This method creates a new PluginConfig instance from the serialized data, - ensuring that validator methods are not included. This is useful when passing - the config to external processes or serializing it. - - Returns: - PluginConfig: A new PluginConfig instance with only data fields. - """ - # Get the JSON-safe dictionary representation - safe_data = self.to_json() - - # Create a new PluginConfig instance from the safe data - # This will run validators again, but the resulting object will be clean - return orjson.dumps(safe_data).decode() - - def to_json(self) -> dict[str, Any]: - """Serialize the PluginConfig object to a JSON-compatible dictionary. - - This method converts the PluginConfig instance to a dictionary that can be - serialized to JSON. It explicitly excludes validator methods and other - non-data attributes, ensuring only the actual configuration fields are included. - - Returns: - dict[str, Any]: A dictionary representation of the PluginConfig object - with all data fields, ready for JSON serialization. - """ - # Get the base serialization from Pydantic - data = self.model_dump(mode="json", exclude_none=False, exclude_unset=False) - - return data - - -class Monorepo(BaseModel): - """Monorepo model. - Attributes: - repo_url (str): The URL of the git monorepo. e.g. https://github.ibm.com/habeck/contextforge-plugins-python - package_source (str): The URL of a specifc plugin folder in the git monorepo - e.g. pii_filter - The cpex cli injects the value when it scans the repo. - """ - - repo_url: str - package_source: str - package_folder: str - - -class PyPiRepo(BaseModel): - """PyPi model. - Attributes: - name (str): The name of the pypi package. - """ - - pypi_package: str - version_constraint: Optional[str] = None - - @field_validator("pypi_package", mode="after") - @classmethod - def validate_pypi_package(cls, pypi_package: str | None) -> str | None: - """Validate PyPI package name format. - - Args: - pypi_package: The PyPI package name to validate. - - Returns: - The validated package name or None if none is set. - - Raises: - ValueError: If the package name is invalid. - """ - if pypi_package is not None and pypi_package != "": - # PyPI package names must contain only ASCII letters, numbers, hyphens, underscores, and periods - # They cannot start or end with hyphens or periods - if not pypi_package.strip(): - raise ValueError("PyPI package name cannot be empty or whitespace") - - # Check for valid characters - import re - - if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$", pypi_package): - raise ValueError( - f"Invalid PyPI package name '{pypi_package}'. " - "Package names must start and end with a letter or number, " - "and can only contain ASCII letters, numbers, hyphens, underscores, and periods." - ) - - # Check length (PyPI has a 214 character limit for package names) - if len(pypi_package) > 214: - raise ValueError(f"PyPI package name '{pypi_package}' exceeds maximum length of 214 characters") - - return pypi_package if pypi_package != "" else None - - @field_validator("version_constraint", mode="after") - @classmethod - def validate_version_constraint(cls, version_constraint: str | None) -> str | None: - """Validate semantic version constraint format. - - Args: - version_constraint: The version constraint to validate. - - Returns: - The validated version constraint or None if none is set. - - Raises: - ValueError: If the version constraint is invalid. - """ - if version_constraint is not None and version_constraint != "": - if not version_constraint.strip(): - raise ValueError("Version constraint cannot be empty or whitespace") - - # Validate semantic version constraint format (e.g., ">=1.0.0,<2.0.0", "~=1.2.3", "==1.0.0") - import re - - # Pattern for version specifiers: operator + optional space + version number - version_pattern = re.compile(r"^(==|!=|<=|>=|<|>|~=|===)\s*" r"\d+(\.\d+)*" r"([a-zA-Z0-9._-]*)?$") - - # Split by comma for multiple constraints - constraints = [c.strip() for c in version_constraint.split(",")] - - for constraint in constraints: - if not constraint: - raise ValueError("Version constraint cannot contain empty parts") - - if not version_pattern.match(constraint): - raise ValueError( - f"Invalid version constraint '{constraint}'. " - "Must follow PEP 440 format (e.g., '>=1.0.0', '~=1.2.3', '==1.0.0,<2.0.0')" - ) - - if len(version_constraint) > 255: - raise ValueError(f"Version constraint '{version_constraint}' exceeds maximum length of 255 characters") - - return version_constraint if version_constraint != "" else None - - -class GitRepo(BaseModel): - """Git repository model. - Attributes: - git_repository: The URL of the git repository. - git_branch_tag_commit: The branch, tag or commit of the git repository. - """ - - git_repository: str = Field( - title="URL", - description='The URL of the git repository. (e.g., "https://github.com/example/plugin.git")', - ) - git_branch_tag_commit: Optional[str] = Field( - title="Branch, tag or commit", - description="The branch, tag or commit of the git repository.", - ) - - @field_validator("git_repository", mode="after") - @classmethod - def validate_git_repository(cls, git_repository: str | None) -> str | None: - """Validate Git repository URL format. - - Args: - git_repository: The Git repository URL to validate. - - Returns: - The validated repository URL or None if none is set. - - Raises: - ValueError: If the repository URL is invalid. - """ - if git_repository is not None and git_repository != "": - if not git_repository.strip(): - raise ValueError("Git repository URL cannot be empty or whitespace") - - # Support common Git URL formats: https://, git://, ssh://, git@ - git_url_pattern = re.compile( - r"^(https?://|git://|git@)" r"[a-zA-Z0-9._-]+" r"(/|:)" r"[a-zA-Z0-9._/-]+" r"(\.git)?$" - ) - - if not git_url_pattern.match(git_repository): - raise ValueError( - f"Invalid Git repository URL '{git_repository}'. " - "Must be a valid Git URL (e.g., https://github.com/user/repo.git, " - "git@github.com:user/repo.git)" - ) - - # Additional validation for https/http URLs using existing validator - if git_repository.startswith(("http://", "https://")): - validate_plugin_url(git_repository, "Git repository URL") - - return git_repository if git_repository != "" else None - - @field_validator("git_branch_tag_commit", mode="after") - @classmethod - def validate_git_branch_tag_commit(cls, git_branch_tag_commit: str | None) -> str | None: - """Validate Git branch, tag, or commit reference. - - Args: - git_branch_tag_commit: The Git reference to validate. - - Returns: - The validated reference or None if none is set. - - Raises: - ValueError: If the reference is invalid. - """ - if git_branch_tag_commit is not None and git_branch_tag_commit != "": - if not git_branch_tag_commit.strip(): - raise ValueError("Git branch/tag/commit cannot be empty or whitespace") - - # Git refs can contain alphanumeric characters, hyphens, underscores, slashes, and periods - # Commit hashes are typically 7-40 hex characters - if not re.match(r"^[a-zA-Z0-9._/-]+$", git_branch_tag_commit): - raise ValueError( - f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " - "Must contain only alphanumeric characters, hyphens, underscores, slashes, and periods." - ) - - # Check for common invalid patterns - if git_branch_tag_commit.startswith(("/", ".", "-")) or git_branch_tag_commit.endswith(("/", ".")): - raise ValueError( - f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " - "Cannot start with /, ., or - or end with / or ." - ) - - if len(git_branch_tag_commit) > 255: - raise ValueError( - f"Git branch/tag/commit '{git_branch_tag_commit}' exceeds maximum length of 255 characters" - ) - - return git_branch_tag_commit if git_branch_tag_commit != "" else None - - -class PluginManifest(BaseModel): - """Plugin manifest. - - Attributes: - name (str): The name of the plugin. - kind (str): The class name (for native plugins) | external | isolated_venv - description (str): A description of the plugin. - author (str): The author of the plugin. - version (str): version of the plugin. - tags (list[str]): a list of tags for making the plugin searchable. - available_hooks (list[str]): a list of the hook points where the plugin is callable. - default_config (dict[str, Any]): the default configurations. - monorepo (Monorepo): A git monorepo where the plugin originates (Initialized by cepx cli during plugin installation) - package_info: (PyPiRepo): The package name and version constraint of the package (Initialized by cepx cli during plugin installation) - local: The path to the locally installed plugin (Initialized by cepx cli during plugin installation) - git_repo: GitRepo: The git repo where the plugin originates (Initialized by cepx cli during plugin installation) - """ - - name: str - kind: str - description: str - author: str - version: str - tags: list[str] - available_hooks: list[str] - default_config: dict[str, Any] - monorepo: Optional[Monorepo] = None - package_info: Optional[PyPiRepo] = None - local: Optional[str] = None - git_repo: Optional[GitRepo] = None - - def suggest_instance_name(self) -> str: - """Suggest a name for the plugin instance. - Returns: - str: A suggested name for the plugin instance. - """ - return self.name.lower().replace(" ", "-") - - def create_instance_config( - self, instance_name: str, mode: PluginMode, priority: int = 100, config: Optional[dict[str, Any]] = None - ) -> PluginConfig: - """Create a plugin instance config. - Returns: - PluginConfig: A plugin instance config. - """ - new_config = self.default_config.copy() - if config is not None: - new_config.update(config) - return PluginConfig( - name=instance_name, - kind=self.kind, - mode=mode, - priority=priority, - description=self.description, - author=self.author, - version=self.version, - tags=self.tags, - hooks=self.available_hooks, - config=new_config, - ) - - -class PluginErrorModel(BaseModel): - """A plugin error, used to denote exceptions/errors inside external plugins. - - Attributes: - message (str): the reason for the error. - code (str): an error code. - details: (dict[str, Any]): additional error details. - plugin_name (str): the plugin name. - mcp_error_code ([int]): The MCP error code passed back to the client. Defaults to Internal Error. - """ - - message: str - plugin_name: str - code: Optional[str] = "" - details: Optional[dict[str, Any]] = Field(default_factory=dict) - mcp_error_code: int = -32603 - - -class PluginViolation(BaseModel): - """A plugin violation, used to denote policy violations. - - Attributes: - reason (str): the reason for the violation. - description (str): a longer description of the violation. - code (str): a violation code. - details: (dict[str, Any]): additional violation details. - _plugin_name (str): the plugin name, private attribute set by the plugin manager. - mcp_error_code(Optional[int]): A valid mcp error code which will be sent back to the client if plugin enabled. - http_status_code (Optional[int]): HTTP status code to return (e.g., 429 for rate limiting). - http_headers (Optional[dict[str, str]]): HTTP headers to include in the response. - - Examples: - >>> violation = PluginViolation( - ... reason="Invalid input", - ... description="The input contains prohibited content", - ... code="PROHIBITED_CONTENT", - ... details={"field": "message", "value": "test"} - ... ) - >>> violation.reason - 'Invalid input' - >>> violation.code - 'PROHIBITED_CONTENT' - >>> violation.plugin_name = "content_filter" - >>> violation.plugin_name - 'content_filter' - """ - - reason: str - description: str - code: str - details: Optional[dict[str, Any]] = Field(default_factory=dict) - _plugin_name: str = PrivateAttr(default="") - mcp_error_code: Optional[int] = None - http_status_code: Optional[int] = None - http_headers: Optional[dict[str, str]] = None - - @property - def plugin_name(self) -> str: - """Getter for the plugin name attribute. - - Returns: - The plugin name associated with the violation. - """ - return self._plugin_name - - @plugin_name.setter - def plugin_name(self, name: str) -> None: - """Setter for the plugin_name attribute. - - Args: - name: the plugin name. - - Raises: - ValueError: if name is empty or not a string. - """ - if not isinstance(name, str) or not name.strip(): - raise ValueError("Name must be a non-empty string.") - self._plugin_name = name - - -class UserContext(BaseModel): - """Authenticated user identity context for propagation to upstream servers and plugins. - - Attributes: - user_id: Primary user identifier (typically email). - email: User email address. - full_name: User's display name. - is_admin: Whether the user has admin privileges. - groups: User's group memberships. - roles: User's RBAC roles. - team_id: Current team context (for single-team API tokens). - teams: All teams the user belongs to. - department: User's department. - attributes: Additional user attributes (extensible). - auth_method: How the user authenticated (bearer, api_key, basic, sso, proxy). - authenticated_at: When the authentication occurred. - service_account: Set when a service account is acting on behalf of this user. - delegation_chain: Chain of delegated identities for audit trail. - - Examples: - >>> uc = UserContext(user_id="alice@example.com") - >>> uc.user_id - 'alice@example.com' - >>> uc.is_admin - False - >>> uc.groups - [] - >>> uc2 = UserContext(user_id="bob@example.com", email="bob@example.com", is_admin=True, auth_method="bearer") - >>> uc2.is_admin - True - >>> uc2.auth_method - 'bearer' - """ - - user_id: str - email: Optional[str] = None - full_name: Optional[str] = None - is_admin: bool = False - groups: list[str] = Field(default_factory=list) - roles: list[str] = Field(default_factory=list) - team_id: Optional[str] = None - teams: Optional[list[str]] = None - department: Optional[str] = None - attributes: dict[str, Any] = Field(default_factory=dict) - auth_method: Optional[str] = None - authenticated_at: Optional[datetime] = None - service_account: Optional[str] = None - delegation_chain: list[str] = Field(default_factory=list) - - -class Config(BaseModel): - """Configurations for plugins. - - Attributes: - plugins (Optional[list[PluginConfig]]): the list of plugins to enable. - plugin_dirs (list[str]): The directories in which to look for plugins. - plugin_settings (Optional[dict]): ignored; runtime settings are read from env vars via settings.py. - server_settings (Optional[MCPServerConfig]): Server-side MCP configuration (when plugins run as server). - grpc_server_settings (Optional[GRPCServerConfig]): Server-side gRPC configuration (when plugins run as gRPC server). - unix_socket_server_settings (Optional[UnixSocketServerConfig]): Server-side Unix socket configuration. - """ - - model_config = ConfigDict(extra="ignore") - - plugins: Optional[list[PluginConfig]] = [] - plugin_dirs: list[str] = [] - server_settings: Optional[MCPServerConfig] = None - grpc_server_settings: Optional[GRPCServerConfig] = None - unix_socket_server_settings: Optional[UnixSocketServerConfig] = None - - -class PluginResult(BaseModel, Generic[T]): - """A result of the plugin hook processing. The actual type is dependent on the hook. - - Attributes: - continue_processing (bool): Whether to stop processing. - modified_payload (Optional[Any]): The modified payload if the plugin is a transformer. - modified_extensions (Optional[Extensions]): Modified extensions returned by the plugin - (e.g., updated HTTP headers from token delegation, appended security labels). - violation (Optional[PluginViolation]): violation object. - metadata (Optional[dict[str, Any]]): additional metadata. - background_tasks (list[asyncio.Task]): asyncio.Task handles for any FIRE_AND_FORGET - plugins scheduled during this invocation. Use ``wait_for_background_tasks()`` - to await them and collect any errors. This field is excluded from model serialization. - http_headers (Optional[dict[str, str]]): HTTP headers to include in successful responses. - retry_delay_ms (int): Milliseconds the gateway should wait before retrying the tool call. - - Examples: - >>> result = PluginResult() - >>> result.continue_processing - True - >>> result.metadata - {} - >>> from cpex.framework import PluginViolation - >>> violation = PluginViolation( - ... reason="Test", description="Test desc", code="TEST", details={} - ... ) - >>> result2 = PluginResult(continue_processing=False, violation=violation) - >>> result2.continue_processing - False - >>> result2.violation.code - 'TEST' - >>> r = PluginResult(metadata={"key": "value"}) - >>> r.metadata["key"] - 'value' - >>> r2 = PluginResult(continue_processing=False) - >>> r2.continue_processing - False - >>> r3 = PluginResult(retry_delay_ms=500) - >>> r3.retry_delay_ms - 500 - """ - - model_config = ConfigDict(arbitrary_types_allowed=True) - - continue_processing: bool = True - modified_payload: Optional[T] = None - modified_extensions: Optional[Extensions] = None - violation: Optional[PluginViolation] = None - metadata: Optional[dict[str, Any]] = Field(default_factory=dict) - background_tasks: list[asyncio.Task] = Field(default_factory=list, exclude=True) - http_headers: Optional[dict[str, str]] = None - retry_delay_ms: int = 0 - - async def wait_for_background_tasks(self) -> "list[PluginErrorModel]": - """Await all FIRE_AND_FORGET background tasks and return any errors. - - Returns an empty list if all tasks completed without error. - - Examples: - >>> result = PluginResult() - >>> # errors = await result.wait_for_background_tasks() - """ - if not self.background_tasks: - return [] - results = await asyncio.gather(*self.background_tasks, return_exceptions=True) - return [r for r in results if isinstance(r, PluginErrorModel)] - - -class GlobalContext(BaseModel): - """The global context, which shared across all plugins. - - Attributes: - request_id (str): ID of the HTTP request. - user (str): user ID associated with the request. - user_context (Optional[UserContext]): structured user identity context. - tenant_id (str): tenant ID. - server_id (str): server ID. - content_type (Optional[str]): Content-Type header from the request. - metadata (Optional[dict[str,Any]]): a global shared metadata across plugins (Read-only from plugin's perspective). - state (Optional[dict[str,Any]]): a global shared state across plugins. - - Examples: - >>> ctx = GlobalContext(request_id="req-123") - >>> ctx.request_id - 'req-123' - >>> ctx.user is None - True - >>> ctx2 = GlobalContext(request_id="req-456", user="alice", tenant_id="tenant1") - >>> ctx2.user - 'alice' - >>> ctx2.tenant_id - 'tenant1' - >>> c = GlobalContext(request_id="123", server_id="srv1") - >>> c.request_id - '123' - >>> c.server_id - 'srv1' - >>> ctx3 = GlobalContext(request_id="req-789", content_type="application/json") - >>> ctx3.content_type - 'application/json' - """ - - request_id: str - user: Optional[Union[str, dict[str, Any]]] = None - user_context: Optional[UserContext] = None - tenant_id: Optional[str] = None - server_id: Optional[str] = None - content_type: Optional[str] = None - state: dict[str, Any] = Field(default_factory=dict) - metadata: dict[str, Any] = Field(default_factory=dict) - - @field_validator("content_type") - @classmethod - def validate_content_type(cls, value: str | None) -> str | None: - """Validate content type length and character safety. - - Args: - value: str of content type. - - Raises: - ValueError: if value is length > 200 or contains non-printable characters. - - Returns: - validated content type. - """ - if value is None: - return value - if len(value) > 200: - raise ValueError("Content-Type header too long") - if not value.isprintable(): - raise ValueError("Content-Type contains invalid characters") - return value - - -class PluginContext(BaseModel): - """The plugin's context, which lasts a request lifecycle. - - Attributes: - state: the inmemory state of the request. - global_context: the context that is shared across plugins. - metadata: plugin meta data. - - Examples: - >>> gctx = GlobalContext(request_id="req-123") - >>> ctx = PluginContext(global_context=gctx) - >>> ctx.global_context.request_id - 'req-123' - >>> ctx.global_context.user is None - True - >>> ctx.state["somekey"] = "some value" - >>> ctx.state["somekey"] - 'some value' - """ - - state: dict[str, Any] = Field(default_factory=dict) - global_context: GlobalContext - metadata: dict[str, Any] = Field(default_factory=dict) - - @property - def user_context(self) -> Optional[UserContext]: - """Get the authenticated user context. - - Returns: - The UserContext if available, None otherwise. - - Examples: - >>> gctx = GlobalContext(request_id="req-1") - >>> ctx = PluginContext(global_context=gctx) - >>> ctx.user_context is None - True - """ - return self.global_context.user_context - - @property - def user_email(self) -> Optional[str]: - """Get the authenticated user's email. - - Falls back to the legacy ``global_context.user`` field when no - structured UserContext is available. - - Returns: - User email string or None. - - Examples: - >>> gctx = GlobalContext(request_id="req-1", user="alice@example.com") - >>> ctx = PluginContext(global_context=gctx) - >>> ctx.user_email - 'alice@example.com' - """ - uc = self.global_context.user_context - if uc: - return uc.email - user = self.global_context.user - if isinstance(user, str): - return user - if isinstance(user, dict): - return user.get("email") - return None - - @property - def user_groups(self) -> list[str]: - """Get the authenticated user's groups. - - Returns: - List of group names. Empty if no user context. - - Examples: - >>> gctx = GlobalContext(request_id="req-1") - >>> ctx = PluginContext(global_context=gctx) - >>> ctx.user_groups - [] - """ - uc = self.global_context.user_context - return uc.groups if uc else [] - - def get_state(self, key: str, default: Any = None) -> Any: - """Get value from shared state. - - Args: - key: The key to access the shared state. - default: A default value if one doesn't exist. - - Returns: - The state value. - """ - return self.state.get(key, default) - - def set_state(self, key: str, value: Any) -> None: - """Set value in shared state. - - Args: - key: the key to add to the state. - value: the value to add to the state. - """ - self.state[key] = value - - async def cleanup(self) -> None: - """Cleanup context resources.""" - self.state.clear() - self.metadata.clear() - - def is_empty(self) -> bool: - """Check whether the state and metadata objects are empty. - - Returns: - True if the context state and metadata are empty. - """ - return not (self.state or self.metadata or self.global_context.state) - - -PluginContextTable = dict[str, PluginContext] - - -class PluginPayload(BaseModel): - """Base class for all hook payloads. Immutable by design. - - Frozen payloads prevent in-place mutations by plugins -- attributes - cannot be set directly on the object. Plugins must use - ``model_copy(update=...)`` to create modified payloads and return - modifications via ``PluginResult.modified_payload``. - - Examples: - >>> class TestPayload(PluginPayload): - ... name: str - >>> p = TestPayload(name="test") - >>> p.name - 'test' - """ - - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - -class PluginPackageInfo(BaseModel): - """Plugin package information. - - Defines how to install a plugin: - - `pypi_package`: Install from PyPI (e.g., "apex-pii-filter") - - `git_repository`: Install from Git (e.g., "https://github.com/example/plugin.git") - - `git_branch/tag/commit`: Specify which version to clone - - `version_constraint`: Semantic version constraints (e.g., ">=1.0.0,<2.0.0") - - Examples: - >>> pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", - git_branch_tag_commit="v1.0.0", - version_constraint=">=1.0.0") - >>> pkg2 = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") - """ - - pypi_package: Optional[str] = None - git_repository: Optional[str] = None - git_branch_tag_commit: Optional[str] = None - version_constraint: Optional[str] = None - - @field_validator("pypi_package", mode="after") - @classmethod - def validate_pypi_package(cls, pypi_package: str | None) -> str | None: - """Validate PyPI package name format. - - Args: - pypi_package: The PyPI package name to validate. - - Returns: - The validated package name or None if none is set. - - Raises: - ValueError: If the package name is invalid. - """ - if pypi_package is not None and pypi_package != "": - # PyPI package names must contain only ASCII letters, numbers, hyphens, underscores, and periods - # They cannot start or end with hyphens or periods - if not pypi_package.strip(): - raise ValueError("PyPI package name cannot be empty or whitespace") - - if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$", pypi_package): - raise ValueError( - f"Invalid PyPI package name '{pypi_package}'. " - "Package names must start and end with a letter or number, " - "and can only contain ASCII letters, numbers, hyphens, underscores, and periods." - ) - - # Check length (PyPI has a 214 character limit for package names) - if len(pypi_package) > 214: - raise ValueError(f"PyPI package name '{pypi_package}' exceeds maximum length of 214 characters") - - return pypi_package if pypi_package != "" else None - - @field_validator("git_repository", mode="after") - @classmethod - def validate_git_repository(cls, git_repository: str | None) -> str | None: - """Validate Git repository URL format. - - Args: - git_repository: The Git repository URL to validate. - - Returns: - The validated repository URL or None if none is set. - - Raises: - ValueError: If the repository URL is invalid. - """ - if git_repository is not None and git_repository != "": - if not git_repository.strip(): - raise ValueError("Git repository URL cannot be empty or whitespace") - - # Support common Git URL formats: https://, git://, ssh://, git@ - git_url_pattern = re.compile( - r"^(https?://|git://|git@)" r"[a-zA-Z0-9._-]+" r"(/|:)" r"[a-zA-Z0-9._/-]+" r"(\.git)?$" - ) - - if not git_url_pattern.match(git_repository): - raise ValueError( - f"Invalid Git repository URL '{git_repository}'. " - "Must be a valid Git URL (e.g., https://github.com/user/repo.git, " - "git@github.com:user/repo.git)" - ) - - # Additional validation for https/http URLs using existing validator - if git_repository.startswith(("http://", "https://")): - validate_plugin_url(git_repository, "Git repository URL") - - return git_repository if git_repository != "" else None - - @field_validator("git_branch_tag_commit", mode="after") - @classmethod - def validate_git_branch_tag_commit(cls, git_branch_tag_commit: str | None) -> str | None: - """Validate Git branch, tag, or commit reference. - - Args: - git_branch_tag_commit: The Git reference to validate. - - Returns: - The validated reference or None if none is set. - - Raises: - ValueError: If the reference is invalid. - """ - if git_branch_tag_commit is not None and git_branch_tag_commit != "": - if not git_branch_tag_commit.strip(): - raise ValueError("Git branch/tag/commit cannot be empty or whitespace") - - # Git refs can contain alphanumeric characters, hyphens, underscores, slashes, and periods - # Commit hashes are typically 7-40 hex characters - if not re.match(r"^[a-zA-Z0-9._/-]+$", git_branch_tag_commit): - raise ValueError( - f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " - "Must contain only alphanumeric characters, hyphens, underscores, slashes, and periods." - ) - - # Check for common invalid patterns - if git_branch_tag_commit.startswith(("/", ".", "-")) or git_branch_tag_commit.endswith(("/", ".")): - raise ValueError( - f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " - "Cannot start with /, ., or - or end with / or ." - ) - - if len(git_branch_tag_commit) > 255: - raise ValueError( - f"Git branch/tag/commit '{git_branch_tag_commit}' exceeds maximum length of 255 characters" - ) - - return git_branch_tag_commit if git_branch_tag_commit != "" else None - - @field_validator("version_constraint", mode="after") - @classmethod - def validate_version_constraint(cls, version_constraint: str | None) -> str | None: - """Validate semantic version constraint format. - - Args: - version_constraint: The version constraint to validate. - - Returns: - The validated version constraint or None if none is set. - - Raises: - ValueError: If the version constraint is invalid. - """ - if version_constraint is not None and version_constraint != "": - if not version_constraint.strip(): - raise ValueError("Version constraint cannot be empty or whitespace") - - # Validate semantic version constraint format (e.g., ">=1.0.0,<2.0.0", "~=1.2.3", "==1.0.0") - # Pattern for version specifiers: operator + optional space + version number - version_pattern = re.compile(r"^(==|!=|<=|>=|<|>|~=|===)\s*" r"\d+(\.\d+)*" r"([a-zA-Z0-9._-]*)?$") - - # Split by comma for multiple constraints - constraints = [c.strip() for c in version_constraint.split(",")] - - for constraint in constraints: - if not constraint: - raise ValueError("Version constraint cannot contain empty parts") - - if not version_pattern.match(constraint): - raise ValueError( - f"Invalid version constraint '{constraint}'. " - "Must follow PEP 440 format (e.g., '>=1.0.0', '~=1.2.3', '==1.0.0,<2.0.0')" - ) - - if len(version_constraint) > 255: - raise ValueError(f"Version constraint '{version_constraint}' exceeds maximum length of 255 characters") - - return version_constraint if version_constraint != "" else None - - @model_validator(mode="after") - def validate_installation_method(self) -> Self: - """Validate that at least one installation method is specified. - - Returns: - The validated model instance. - - Raises: - ValueError: If neither PyPI package nor Git repository is specified. - """ - if not self.pypi_package and not self.git_repository: - raise ValueError( - "At least one installation method must be specified: either 'pypi_package' or 'git_repository'" - ) - - # If git_branch_tag_commit is specified, git_repository must also be specified - if self.git_branch_tag_commit and not self.git_repository: - raise ValueError("'git_branch_tag_commit' can only be specified when 'git_repository' is provided") - - return self - - -class PluginVersionInfo(BaseModel): - """Represents the version information of a plugin. - - Attributes: - version (str): The version of the plugin. - released (str): The release date of the plugin. - breaking_changes: (bool): Whether the version contains breaking changes. - deprecated (bool): Whether the version is deprecated. - manifest_file (str): The manifest file of the plugin. - changelog (str): The release notes for the plugin. - min_max_framework_version (str): The minimum and maximum framework version required for the plugin (comma separated). - """ - - version: str - released: str - breaking_changes: Optional[bool] = None - deprecated: bool = False - manifest_file: str - changelog: Optional[str] = None - min_max_framework_version: Optional[str] = "0.1.0,0.1.0" - - -class PluginVersionRegistry(BaseModel): - """Represents the version registry of a plugin. - Attributes: - versions (List[PluginVersionInfo]): A list of PluginVersionInfo objects representing the different versions of the plugin. - """ - - latest: Optional[PluginVersionInfo] = None - latest_prerelease: Optional[PluginVersionInfo] = None - versions: List[PluginVersionInfo] - - def get_version(self) -> Optional[PluginVersionInfo]: - """Returns the latest version of the plugin. - Returns: - Optional[PluginVersionInfo]: The latest version of the plugin, or None if no version is available. - """ - return self.latest - - def get_latest_compatible(self, framework_version: str) -> Optional[PluginVersionInfo]: - """Returns the latest compatible version for the given framework version. - - Args: - framework_version (str): The framework version to check compatibility against. - - Returns: - Optional[PluginVersionInfo]: The latest compatible version, or None if no compatible version is found. - """ - - try: - fw_version = Version(framework_version) - except InvalidVersion: - logging.getLogger(__name__).warning(f"Invalid framework version format: {framework_version}") - return None - - compatible_versions = [] - - for version_info in self.versions: - if not version_info.min_max_framework_version: - continue - - try: - # Parse min and max framework versions - parts = version_info.min_max_framework_version.split(",") - if len(parts) != 2: - continue - - min_version = Version(parts[0].strip()) - max_version = Version(parts[1].strip()) - - # Check if framework version is within range - if min_version <= fw_version <= max_version: - compatible_versions.append(version_info) - - except (InvalidVersion, ValueError): - continue - - if not compatible_versions: - return None - - # Sort by version and return the latest - try: - sorted_versions = sorted(compatible_versions, key=lambda v: Version(v.version), reverse=True) - return sorted_versions[0] - except InvalidVersion: - # If sorting fails, return the first compatible version - return compatible_versions[0] - - -class PluginInstallationType(StrEnum): - """Plugin installation type.""" - - BUNDLED = "bundled" # Pre-installed with framework - PYPI = "pypi" # Installed from PyPI - GIT = "git" # Installed from Git repo - MONOREPO = "monorepo" # Installed from git monorepo - LOCAL = "local" # Installed from local path - - -class InstalledPluginInfo(BaseModel): - """Plugin installation information.""" - - name: str - kind: str - version: Optional[str] = None - installation_type: PluginInstallationType - installation_path: str - installed_at: str - installed_by: str - package_source: Optional[str] = None - editable: bool = False - - -class InstalledPluginRegistry(BaseModel): - """Installed plugin registry.""" - - plugins: List[InstalledPluginInfo] = [] - - def register_plugin(self, plugin: InstalledPluginInfo) -> None: - """Register a plugin in the registry. - - If a plugin with the same name is already registered, its entry is - replaced so the registry reflects the most-recent install. - """ - self.plugins = [p for p in self.plugins if p.name != plugin.name] - self.plugins.append(plugin) - self.save() - - def unregister_plugin(self, plugin_name: str) -> bool: - """Unregister a plugin from the registry. - - Args: - plugin_name: The name of the plugin to unregister. - - Returns: - True if the plugin was found and removed, False otherwise. - """ - initial_count = len(self.plugins) - self.plugins = [p for p in self.plugins if p.name != plugin_name] - - if len(self.plugins) < initial_count: - self.save() - return True - return False - - def save(self) -> None: - """Serialize the registry to disk atomically.""" - from cpex.tools.settings import get_plugin_registry_path - - target = get_plugin_registry_path() - folder = target.parent - data = orjson.dumps(self.model_dump(), option=orjson.OPT_INDENT_2) - - tmp = tempfile.NamedTemporaryFile( - mode="wb", - delete=False, - dir=str(folder), - prefix="installed-plugins.", - suffix=".tmp", - ) - try: - try: - tmp.write(data) - tmp.flush() - os.fsync(tmp.fileno()) - finally: - tmp.close() - os.replace(tmp.name, target) - except Exception: - with contextlib.suppress(FileNotFoundError): - os.unlink(tmp.name) - raise diff --git a/cpex/framework/observability.py b/cpex/framework/observability.py deleted file mode 100644 index e2bf0256..00000000 --- a/cpex/framework/observability.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/observability.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Observability abstractions for the plugin framework. - -Provides a protocol-based interface for observability so that host -applications can inject their own tracing implementation. -""" - -# Standard -from contextvars import ContextVar -from typing import Any, Dict, Optional, Protocol - -# Context variable for tracking the current trace_id across async calls. -# NOTE: This is bridged from mcpgateway.services.observability_service.current_trace_id -# by ObservabilityMiddleware. Both must be set together; see the middleware for details. -current_trace_id: ContextVar[Optional[str]] = ContextVar("current_trace_id", default=None) - - -class ObservabilityProvider(Protocol): - """Interface for observability - host application implements this.""" - - def start_span( - self, - trace_id: str, - name: str, - kind: str = "internal", - resource_type: Optional[str] = None, # noqa - resource_name: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - ) -> Optional[str]: - """Start a new span within a trace. - - Args: - trace_id: The trace identifier. - name: The span name. - kind: The span kind (e.g. "internal", "client", "server"). - resource_type: Optional resource type being traced. - resource_name: Optional resource name being traced. - attributes: Optional key-value attributes for the span. - """ - ... # pylint: disable=unnecessary-ellipsis - - def end_span( - self, - span_id: Optional[str], - status: str = "ok", - attributes: Optional[Dict[str, Any]] = None, - ) -> None: - """End a previously started span. - - Args: - span_id: The span identifier returned by start_span. - status: The span status (e.g. "ok", "error"). - attributes: Optional additional attributes to attach. - """ - ... # pylint: disable=unnecessary-ellipsis - - -class NullObservability: - """Default no-op implementation for standalone operation.""" - - def start_span( # pylint: disable=unused-argument - self, - trace_id: str, - name: str, - kind: str = "internal", - resource_type: Optional[str] = None, - resource_name: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - ) -> Optional[str]: - """No-op span start for standalone operation. - - Args: - trace_id: The trace identifier. - name: The span name. - kind: The span kind. - resource_type: Optional resource type. - resource_name: Optional resource name. - attributes: Optional span attributes. - - Returns: - Always None (no-op implementation). - """ - return None - - def end_span( # pylint: disable=unused-argument - self, - span_id: Optional[str], - status: str = "ok", - attributes: Optional[Dict[str, Any]] = None, - ) -> None: - """No-op span end for standalone operation. - - Args: - span_id: The span identifier. - status: The span status. - attributes: Optional span attributes. - """ diff --git a/cpex/framework/pdp/__init__.py b/cpex/framework/pdp/__init__.py deleted file mode 100644 index 4941c57f..00000000 --- a/cpex/framework/pdp/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -"""External Policy Decision Point (PDP) resolvers. - -This module provides clients for delegating policy decisions to external -PDPs (OPA, Cedar, AuthZen, or custom) from within the APL pipeline. - -The APL pipeline (Rust) defines the PdpResolver trait. These Python -classes implement that interface, handling the HTTP calls while the -Rust core stays synchronous and transport-agnostic. - -Usage in YAML policy: - policy: - - authzen("https://pdp.corp.com/access/v1/evaluation"): - timeout_ms: 500 - on_error: deny - -Usage from Python (gateway integration): - from cpex.framework.pdp import AuthZenResolver, OpaResolver - - resolver = AuthZenResolver("https://pdp.corp.com/access/v1/evaluation") - # Pass to pipeline executor as the PDP callback -""" - -from cpex.framework.pdp.authzen import AuthZenResolver -from cpex.framework.pdp.base import PdpResolver, PdpResult -from cpex.framework.pdp.opa import OpaResolver - -__all__ = [ - "AuthZenResolver", - "OpaResolver", - "PdpResolver", - "PdpResult", -] diff --git a/cpex/framework/pdp/authzen.py b/cpex/framework/pdp/authzen.py deleted file mode 100644 index 933364af..00000000 --- a/cpex/framework/pdp/authzen.py +++ /dev/null @@ -1,278 +0,0 @@ -# -*- coding: utf-8 -*- -"""AuthZen PDP resolver — OpenID AuthZen Access Evaluation API. - -AuthZen defines a standard interface for policy evaluation that is -PDP-agnostic: the same API works whether the backend is OPA, Cedar, -Topaz, Cerbos, OSO, or any other engine implementing the spec. - -AuthZen API (single evaluation): - POST /access/v1/evaluation - { - "subject": { "type": "user", "id": "alice@corp.com", "properties": {...} }, - "action": { "name": "read" }, - "resource": { "type": "tool", "id": "get_compensation", "properties": {...} }, - "context": { ... } - } - → { "decision": true } - -AuthZen API (batch evaluation): - POST /access/v1/evaluations - { - "evaluations": [ - { "subject": ..., "action": ..., "resource": ..., "context": ... }, - ... - ] - } - → { "evaluations": [{ "decision": true }, { "decision": false }] } - -Mapping from CPEX/APL types to AuthZen: - SubjectExtension → subject (type, id, properties: roles/permissions/teams) - route action → action (name) - tool/resource → resource (type, id, properties) - everything else → context (delegation, session, authorization_details, args) - -References: - - OpenID AuthZen: https://openid.net/specs/openid-authzen-authorization-api-1_0.html - - AuthZen interop: https://authzen.org -""" - -from __future__ import annotations - -import logging -import time -from typing import Any - -import httpx - -from cpex.framework.pdp.base import PdpError, PdpResolver, PdpResult - -logger = logging.getLogger(__name__) - - -class AuthZenResolver(PdpResolver): - """AuthZen Access Evaluation API client. - - Translates CPEX pipeline context into AuthZen's subject/action/resource/context - tuple and calls the evaluation endpoint. - - Args: - endpoint: AuthZen evaluation endpoint URL. Supports {placeholder} - templates interpolated from input_data at request time. - Static: "https://pdp.corp.com/access/v1/evaluation" - Template: "https://pdp.corp.com/access/v1/{tool}/evaluation" - timeout_ms: HTTP timeout in milliseconds. Default 500. - headers: Additional HTTP headers (e.g., API keys, bearer tokens). - fail_open: If True, allow on PDP errors. If False, raise PdpError. - - Usage: - # Static endpoint - resolver = AuthZenResolver("https://pdp.corp.com/access/v1/evaluation") - - # Template endpoint — per-tool policy sets - resolver = AuthZenResolver("https://pdp.corp.com/access/v1/{tool}/evaluation") - - result = await resolver.resolve({ - "tool": "get_compensation", - "action": "read", - ... - }) - """ - - def __init__( - self, - endpoint: str, - timeout_ms: int = 500, - headers: dict[str, str] | None = None, - fail_open: bool = False, - ): - """Initialize the AuthZen resolver. - - Args: - endpoint: AuthZen evaluation endpoint URL. Supports {placeholder} templates. - timeout_ms: HTTP timeout in milliseconds. Default 500. - headers: Additional HTTP headers (e.g., API keys, bearer tokens). - fail_open: If True, allow on PDP errors. If False, raise PdpError. - """ - self._endpoint_template = endpoint - self.fail_open = fail_open - self._client = httpx.AsyncClient( - timeout=httpx.Timeout(timeout_ms / 1000.0), - headers=headers or {}, - ) - - def _resolve_endpoint(self, input_data: dict[str, Any]) -> str: - """Resolve the endpoint URL, interpolating any {placeholders}.""" - if "{" not in self._endpoint_template: - return self._endpoint_template - flat = {k: str(v) for k, v in input_data.items() if isinstance(v, str)} - try: - return self._endpoint_template.format(**flat) - except KeyError: - logger.warning( - "AuthZen endpoint template has unresolved placeholders: %s", - self._endpoint_template, - ) - return self._endpoint_template - - async def resolve(self, input_data: dict[str, Any]) -> PdpResult: - """Evaluate a policy decision via AuthZen. - - Transforms the flat input_data (from APL's build_pdp_input) into - the AuthZen subject/action/resource/context structure. - If the endpoint is a template, placeholders are resolved from input_data. - """ - endpoint = self._resolve_endpoint(input_data) - request_body = self._build_request(input_data) - - start = time.monotonic() - try: - response = await self._client.post( - endpoint, - json=request_body, - ) - latency_ms = (time.monotonic() - start) * 1000 - response.raise_for_status() - return self._parse_response(response.json(), latency_ms) - - except httpx.TimeoutException as e: - latency_ms = (time.monotonic() - start) * 1000 - logger.warning("AuthZen timeout after %.1fms: %s", latency_ms, endpoint) - if self.fail_open: - return PdpResult( - allowed=True, - reason="AuthZen timeout, fail-open", - latency_ms=latency_ms, - ) - raise PdpError( - f"AuthZen timeout after {latency_ms:.0f}ms", - endpoint=endpoint, - cause=e, - ) - - except httpx.HTTPStatusError as e: - latency_ms = (time.monotonic() - start) * 1000 - logger.error( - "AuthZen HTTP %d from %s: %s", - e.response.status_code, - endpoint, - e.response.text[:200], - ) - if self.fail_open: - return PdpResult( - allowed=True, - reason=f"AuthZen HTTP {e.response.status_code}, fail-open", - latency_ms=latency_ms, - ) - raise PdpError( - f"AuthZen HTTP {e.response.status_code}", - endpoint=endpoint, - cause=e, - ) - - except httpx.HTTPError as e: - latency_ms = (time.monotonic() - start) * 1000 - logger.error("AuthZen connection error: %s", e) - if self.fail_open: - return PdpResult( - allowed=True, - reason="AuthZen unreachable, fail-open", - latency_ms=latency_ms, - ) - raise PdpError( - f"AuthZen connection error: {e}", - endpoint=endpoint, - cause=e, - ) - - def _build_request(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Map CPEX pipeline context → AuthZen evaluation request. - - AuthZen expects: - subject: { type, id, properties } - action: { name, properties } - resource: { type, id, properties } - context: { ... everything else ... } - - The input_data comes from APL's build_pdp_input() which extracts - namespaces from the AttributeBag and ContentSurface. - """ - # --- Subject --- - subject_data = input_data.get("subject", {}) - subject = { - "type": subject_data.get("type", "unknown"), - "id": subject_data.get("id", "unknown"), - } - # Everything else in subject_data goes into properties - subject_props = {k: v for k, v in subject_data.items() if k not in ("type", "id")} - if subject_props: - subject["properties"] = subject_props - - # --- Action --- - action_name = input_data.get("action", "unknown") - if isinstance(action_name, dict): - action = action_name - else: - action = {"name": str(action_name)} - - # --- Resource --- - resource_type = "tool" # default for CPEX - resource_id = input_data.get("tool", "unknown") - if isinstance(resource_id, dict): - resource = resource_id - else: - resource = {"type": resource_type, "id": str(resource_id)} - - # --- Context --- - # Everything that isn't subject/action/tool goes into context - context_keys = {"delegation", "session", "authorization_details", "args"} - context = {} - for key in context_keys: - if key in input_data: - context[key] = input_data[key] - - # Include any other keys that aren't part of the standard mapping - standard_keys = {"subject", "action", "tool"} | context_keys - for key, value in input_data.items(): - if key not in standard_keys: - context[key] = value - - request: dict[str, Any] = { - "subject": subject, - "action": action, - "resource": resource, - } - if context: - request["context"] = context - - return request - - def _parse_response(self, body: dict[str, Any], latency_ms: float) -> PdpResult: - """Parse AuthZen evaluation response. - - AuthZen response format: - { "decision": true } - or with context: - { "decision": true, "context": { "reason": { ... } } } - """ - decision = body.get("decision", False) - - # Extract reason from context if present - reason = None - resp_context = body.get("context", {}) - if isinstance(resp_context, dict): - reason_obj = resp_context.get("reason", {}) - if isinstance(reason_obj, str): - reason = reason_obj - elif isinstance(reason_obj, dict): - reason = reason_obj.get("message") or reason_obj.get("detail") - - return PdpResult( - allowed=bool(decision), - reason=reason, - context=resp_context if isinstance(resp_context, dict) else {}, - latency_ms=latency_ms, - ) - - async def close(self) -> None: - """Shut down the HTTP client.""" - await self._client.aclose() diff --git a/cpex/framework/pdp/base.py b/cpex/framework/pdp/base.py deleted file mode 100644 index 3c26bfba..00000000 --- a/cpex/framework/pdp/base.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- -"""Base classes for PDP resolvers. - -Defines the interface that all PDP resolvers implement, and the result -type returned by PDP calls. These mirror the Rust PdpResolver trait -and ExternalPdpResult struct in apl_core. -""" - -from __future__ import annotations - -import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Any - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PdpResult: - """Result of an external PDP evaluation. - - Maps to apl_core::ExternalPdpResult on the Rust side. - """ - - allowed: bool - """Whether the PDP allowed the request.""" - - reason: str | None = None - """Human-readable reason for the decision.""" - - context: dict[str, Any] = field(default_factory=dict) - """Additional context from the PDP (obligations, advice, etc.).""" - - from_cache: bool = False - """Whether this result was served from cache.""" - - latency_ms: float = 0.0 - """Round-trip latency of the PDP call in milliseconds.""" - - -class PdpResolver(ABC): - """Abstract base for external PDP resolvers. - - Implementations handle the transport (HTTP, gRPC, in-process) and - protocol (AuthZen, OPA, Cedar) specifics. The APL pipeline calls - `resolve()` with the input built from the AttributeBag and - ContentSurface, and uses the returned PdpResult to allow/deny. - - Resolvers are expected to be reusable across requests — create - once at gateway startup, call `resolve()` per-request, call - `close()` at shutdown. - """ - - @abstractmethod - async def resolve(self, input_data: dict[str, Any]) -> PdpResult: - """Evaluate a policy decision against the external PDP. - - Args: - input_data: Context extracted from the APL pipeline. - Keys depend on the configured input_namespaces: - - "subject": identity attributes - - "authorization_details": RFC 9396 RAR details - - "delegation": delegation chain attributes - - "session": session state (labels, tool_calls, cost) - - "args": tool call arguments (from ContentSurface) - - "tool": tool name (from static_context) - - "action": route action (from static_context) - - Returns: - PdpResult with the PDP's decision. - - Raises: - PdpError: If the PDP call fails and cannot be handled - by the resolver's retry/fallback logic. - """ - ... - - async def close(self) -> None: - """Clean up resources (HTTP clients, connections). - - Called at gateway shutdown. Override if the resolver holds - persistent connections. - """ - pass - - -class PdpError(Exception): - """Raised when a PDP call fails irrecoverably.""" - - def __init__(self, message: str, endpoint: str, cause: Exception | None = None): - """Initialize a PDP error. - - Args: - message: Human-readable error description. - endpoint: The PDP endpoint that failed. - cause: The underlying exception, if any. - """ - super().__init__(message) - self.endpoint = endpoint - self.cause = cause diff --git a/cpex/framework/pdp/opa.py b/cpex/framework/pdp/opa.py deleted file mode 100644 index 4920c0fc..00000000 --- a/cpex/framework/pdp/opa.py +++ /dev/null @@ -1,223 +0,0 @@ -# -*- coding: utf-8 -*- -"""OPA PDP resolver — Open Policy Agent Data API. - -OPA's Data API evaluates a Rego policy against an input document: - - POST /v1/data/{policy_path} - { "input": { ... } } - → { "result": true } - -Unlike AuthZen, OPA's input is free-form — whatever the Rego policy -expects. We pass the CPEX pipeline context as-is under the "input" key, -so Rego policies can reference it directly: - - package cpex.authz - - default allow = false - - allow { - some detail in input.authorization_details.types - detail == "tool_invocation" - "read" in input.authorization_details.actions - input.delegation.depth <= 3 - } - - deny[msg] { - input.session.labels[_] == "PII" - input.action == "forward" - msg := "Cannot forward PII-tainted data" - } - -References: - - OPA REST API: https://www.openpolicyagent.org/docs/latest/rest-api/ -""" - -from __future__ import annotations - -import logging -import time -from typing import Any - -import httpx - -from cpex.framework.pdp.base import PdpError, PdpResolver, PdpResult - -logger = logging.getLogger(__name__) - - -class OpaResolver(PdpResolver): - """Open Policy Agent Data API client. - - Sends CPEX pipeline context as OPA input and reads the decision - from the result. - - Args: - endpoint: OPA policy data endpoint. Supports {placeholder} templates - that are interpolated from the input_data at request time. - Static: "http://opa:8181/v1/data/cpex/authz/allow" - Template: "http://opa:8181/v1/data/cpex/tools/{tool}/allow" - timeout_ms: HTTP timeout in milliseconds. Default 500. - headers: Additional HTTP headers. - fail_open: If True, allow on OPA errors. If False, raise PdpError. - - Usage: - # Static endpoint — same OPA package for all tools - resolver = OpaResolver("http://opa:8181/v1/data/cpex/authz/allow") - - # Template endpoint — per-tool OPA packages - resolver = OpaResolver("http://opa:8181/v1/data/cpex/tools/{tool}/allow") - - result = await resolver.resolve({ - "tool": "get_compensation", - "action": "read", - ... - }) - # With template: hits /v1/data/cpex/tools/get_compensation/allow - """ - - def __init__( - self, - endpoint: str, - timeout_ms: int = 500, - headers: dict[str, str] | None = None, - fail_open: bool = False, - ): - """Initialize the OPA resolver. - - Args: - endpoint: OPA policy data endpoint URL. Supports {placeholder} templates. - timeout_ms: HTTP timeout in milliseconds. Default 500. - headers: Additional HTTP headers. - fail_open: If True, allow on OPA errors. If False, raise PdpError. - """ - self._endpoint_template = endpoint - self.fail_open = fail_open - self._client = httpx.AsyncClient( - timeout=httpx.Timeout(timeout_ms / 1000.0), - headers=headers or {}, - ) - - def _resolve_endpoint(self, input_data: dict[str, Any]) -> str: - """Resolve the endpoint URL, interpolating any {placeholders}.""" - if "{" not in self._endpoint_template: - return self._endpoint_template - # Flatten input_data to string values for interpolation - flat = {k: str(v) for k, v in input_data.items() if isinstance(v, str)} - try: - return self._endpoint_template.format(**flat) - except KeyError: - # Missing placeholder — use template as-is - logger.warning( - "OPA endpoint template has unresolved placeholders: %s", - self._endpoint_template, - ) - return self._endpoint_template - - async def resolve(self, input_data: dict[str, Any]) -> PdpResult: - """Evaluate a policy decision via OPA. - - The input_data is passed directly as OPA's `input` document. - Rego policies reference fields as `input.delegation.depth`, etc. - If the endpoint is a template, placeholders are resolved from input_data. - """ - endpoint = self._resolve_endpoint(input_data) - request_body = {"input": input_data} - - start = time.monotonic() - try: - response = await self._client.post( - endpoint, - json=request_body, - ) - latency_ms = (time.monotonic() - start) * 1000 - response.raise_for_status() - return self._parse_response(response.json(), latency_ms) - - except httpx.TimeoutException as e: - latency_ms = (time.monotonic() - start) * 1000 - logger.warning("OPA timeout after %.1fms: %s", latency_ms, endpoint) - if self.fail_open: - return PdpResult( - allowed=True, - reason="OPA timeout, fail-open", - latency_ms=latency_ms, - ) - raise PdpError( - f"OPA timeout after {latency_ms:.0f}ms", - endpoint=endpoint, - cause=e, - ) - - except httpx.HTTPStatusError as e: - latency_ms = (time.monotonic() - start) * 1000 - logger.error("OPA HTTP %d from %s", e.response.status_code, endpoint) - if self.fail_open: - return PdpResult( - allowed=True, - reason=f"OPA HTTP {e.response.status_code}, fail-open", - latency_ms=latency_ms, - ) - raise PdpError( - f"OPA HTTP {e.response.status_code}", - endpoint=endpoint, - cause=e, - ) - - except httpx.HTTPError as e: - latency_ms = (time.monotonic() - start) * 1000 - logger.error("OPA connection error: %s", e) - if self.fail_open: - return PdpResult( - allowed=True, - reason="OPA unreachable, fail-open", - latency_ms=latency_ms, - ) - raise PdpError( - f"OPA connection error: {e}", - endpoint=endpoint, - cause=e, - ) - - def _parse_response(self, body: dict[str, Any], latency_ms: float) -> PdpResult: - """Parse OPA Data API response. - - OPA returns different shapes depending on the policy: - { "result": true } — boolean policy - { "result": { "allow": true } } — structured policy - { "result": { "allow": true, "deny": [] } } — allow + deny reasons - """ - result = body.get("result") - - if isinstance(result, bool): - return PdpResult(allowed=result, latency_ms=latency_ms) - - if isinstance(result, dict): - allowed = result.get("allow", False) - reason = None - - # Extract deny reasons if present - deny_reasons = result.get("deny", []) - if deny_reasons: - if isinstance(deny_reasons, list) and deny_reasons: - reason = deny_reasons[0] if isinstance(deny_reasons[0], str) else str(deny_reasons[0]) - elif isinstance(deny_reasons, str): - reason = deny_reasons - - return PdpResult( - allowed=bool(allowed), - reason=reason, - context=result, - latency_ms=latency_ms, - ) - - # Unexpected format — log and use fail mode - logger.warning("Unexpected OPA response format: %s", body) - return PdpResult( - allowed=self.fail_open, - reason=f"Unexpected OPA response: {type(result).__name__}", - latency_ms=latency_ms, - ) - - async def close(self) -> None: - """Shut down the HTTP client.""" - await self._client.aclose() diff --git a/cpex/framework/protocols.py b/cpex/framework/protocols.py deleted file mode 100644 index d51d9037..00000000 --- a/cpex/framework/protocols.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/protocols.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Protocol definitions for types that cross the gateway-framework boundary. - -These protocols allow the framework to express structural -contracts without creating a dependency on the outer package. - -Examples: - >>> from cpex.framework.protocols import MessageLike, PromptResultLike - >>> import typing - >>> typing.runtime_checkable(MessageLike) # Already decorated - -""" - -# Standard -from typing import Any, Optional, Protocol, Sequence, runtime_checkable - - -@runtime_checkable -class MessageLike(Protocol): - """Structural contract for message objects. - - The framework never instantiates Message directly -- it receives - them from the service layer. Any object with ``role`` and - ``content`` attributes satisfies this protocol structurally. - - Attributes: - role: str or Role enum indicating the message sender. - content: TextContent, ImageContent, or other content type. - - Examples: - >>> from types import SimpleNamespace - >>> msg = SimpleNamespace(role="user", content="hello") - >>> isinstance(msg, MessageLike) - True - """ - - role: str - content: Any - - -@runtime_checkable -class PromptResultLike(Protocol): - """Structural contract for prompt result objects. - - The framework never instantiates PromptResult directly -- it - receives them from the service layer. Any object with - ``messages`` and ``description`` attributes satisfies this - protocol structurally. - - Attributes: - messages: Sequence of MessageLike objects. - description: Optional description of the rendered result. - - Examples: - >>> from types import SimpleNamespace - >>> result = SimpleNamespace(messages=[], description=None) - >>> isinstance(result, PromptResultLike) - True - """ - - messages: Sequence[MessageLike] - description: Optional[str] diff --git a/cpex/framework/registry.py b/cpex/framework/registry.py deleted file mode 100644 index 52632726..00000000 --- a/cpex/framework/registry.py +++ /dev/null @@ -1,218 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/registry.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Plugin instance registry. -Module that stores plugin instances and manages hook points. -""" - -# Standard -import logging -from collections import defaultdict -from typing import Optional - -# First-Party -from cpex.framework.base import HookRef, Plugin, PluginRef -from cpex.framework.external.mcp.client import ExternalHookRef -from cpex.framework.models import PluginConfig - -# Use standard logging to avoid circular imports (plugins -> services -> plugins) -logger = logging.getLogger(__name__) - - -class PluginInstanceRegistry: - """Registry for managing loaded plugins. - - Examples: - >>> from cpex.framework import Plugin, PluginConfig - >>> from cpex.framework.hooks.prompts import PromptHookType - >>> registry = PluginInstanceRegistry() - >>> config = PluginConfig( - ... name="test", - ... description="Test", - ... author="test", - ... kind="test.Plugin", - ... version="1.0", - ... hooks=[PromptHookType.PROMPT_PRE_FETCH], - ... tags=[] - ... ) - >>> async def prompt_pre_fetch(payload, context): ... - >>> plugin = Plugin(config) - >>> plugin.prompt_pre_fetch = prompt_pre_fetch - >>> registry.register(plugin) - >>> registry.get_plugin("test").name - 'test' - >>> len(registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH)) - 1 - >>> registry.unregister("test") - >>> registry.get_plugin("test") is None - True - """ - - def __init__(self) -> None: - """Initialize a plugin instance registry. - - Examples: - >>> registry = PluginInstanceRegistry() - >>> isinstance(registry._plugins, dict) - True - >>> isinstance(registry._hooks, dict) - True - >>> len(registry._plugins) - 0 - """ - self._plugins: dict[str, PluginRef] = {} - self._hooks: dict[str, list[HookRef]] = defaultdict(list) - self._hooks_by_name: dict[str, dict[str, HookRef]] = {} - self._priority_cache: dict[str, list[HookRef]] = {} - - def register( - self, - plugin: Plugin, - trusted_config: PluginConfig | None = None, - ) -> None: - """Register a plugin instance. - - Args: - plugin: plugin to be registered. - trusted_config: The authoritative config retained by the - Manager. If provided, PluginRef reads policy fields - from this copy rather than from the plugin. - - Raises: - ValueError: if plugin is already registered. - """ - if plugin.name in self._plugins: - raise ValueError(f"Plugin {plugin.name} already registered") - - plugin_ref = PluginRef(plugin, trusted_config=trusted_config) - - self._plugins[plugin.name] = plugin_ref - - plugin_hooks = {} - - # Check if this is an external plugin by looking for the invoke_hook method - # External plugins (MCP, gRPC, Unix socket) use invoke_hook instead of direct hook methods - external = hasattr(plugin, "invoke_hook") and callable(getattr(plugin, "invoke_hook")) - - # Register hooks - for hook_type in plugin.hooks: - hook_ref: HookRef - if external: - hook_ref = ExternalHookRef(hook_type, plugin_ref) - else: - hook_ref = HookRef(hook_type, plugin_ref) - self._hooks[hook_type].append(hook_ref) - plugin_hooks[hook_type] = hook_ref - # Invalidate priority cache for this hook - self._priority_cache.pop(hook_type, None) - self._hooks_by_name[plugin.name] = plugin_hooks - - logger.info(f"Registered plugin: {plugin.name} with hooks: {list(plugin.hooks)}") - - def unregister(self, plugin_name: str) -> None: - """Unregister a plugin given its name. - - Args: - plugin_name: The name of the plugin to unregister. - - Returns: - None - """ - if plugin_name not in self._plugins: - return - - plugin = self._plugins.pop(plugin_name) - # Remove from hooks - for hook_type in plugin.hooks: - self._hooks[hook_type] = [p for p in self._hooks[hook_type] if p.plugin_ref.name != plugin_name] - self._priority_cache.pop(hook_type, None) - - # Remove from hooks by name - self._hooks_by_name.pop(plugin_name, None) - - logger.info(f"Unregistered plugin: {plugin_name}") - - def get_plugin(self, name: str) -> Optional[PluginRef]: - """Get a plugin by name. - - Args: - name: the name of the plugin to return. - - Returns: - A plugin. - """ - return self._plugins.get(name) - - def get_plugin_hook_by_name(self, name: str, hook_type: str) -> Optional[HookRef]: - """Gets a hook reference for a particular plugin and hook type. - - Args: - name: plugin name. - hook_type: the hook type. - - Returns: - A hook reference for the plugin or None if not found. - """ - if name in self._hooks_by_name: - hooks = self._hooks_by_name[name] - if hook_type in hooks: - return hooks[hook_type] - return None - - def get_hook_refs_for_hook(self, hook_type: str) -> list[HookRef]: - """Get all plugins for a specific hook, sorted by priority. - - Args: - hook_type: the hook type. - - Returns: - A list of plugin instances. - """ - if hook_type not in self._priority_cache: - hook_refs = sorted(self._hooks[hook_type], key=lambda p: p.plugin_ref.priority) - self._priority_cache[hook_type] = hook_refs - return self._priority_cache[hook_type] - - def get_all_plugins(self) -> list[PluginRef]: - """Get all registered plugin instances. - - Returns: - A list of registered plugin instances. - """ - return list(self._plugins.values()) - - def has_hooks_for(self, hook_type: str) -> bool: - """Check if there are any hooks registered for a specific hook type. - - Args: - hook_type: The type of hook to check for. - - Returns: - bool: True if there are hooks registered for the specified type, False otherwise. - """ - return bool(self._hooks.get(hook_type)) - - @property - def plugin_count(self) -> int: - """Return the number of plugins registered. - - Returns: - The number of plugins registered. - """ - return len(self._plugins) - - async def shutdown(self) -> None: - """Shutdown all plugins.""" - # Must cleanup the plugins in reverse of creating them to handle asyncio cleanup issues. - # https://github.com/microsoft/semantic-kernel/issues/12627 - for plugin_ref in reversed(self._plugins.values()): - try: - await plugin_ref.plugin.shutdown() - except Exception as e: - logger.error(f"Error shutting down plugin {plugin_ref.plugin.name}: {e}") - self._plugins.clear() - self._hooks.clear() - self._priority_cache.clear() diff --git a/cpex/framework/settings.py b/cpex/framework/settings.py deleted file mode 100644 index 74442245..00000000 --- a/cpex/framework/settings.py +++ /dev/null @@ -1,699 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/settings.py - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Plugin framework configuration. - -Self-contained settings for the plugin framework. -""" - -# Standard -import logging -import os -from functools import lru_cache -from typing import Any, Literal - -# Third-Party -from pydantic import AliasChoices, Field, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - -logger = logging.getLogger(__name__) - - -def _empty_string_to_none(value: Any) -> Any: - """Treat empty optional env vars as unset (None). - - Shared validator for optional fields that may arrive as empty strings - from the environment. Used by ``@field_validator(..., mode="before")`` - across multiple lightweight settings classes. - - Args: - value: The raw value from the environment variable. - - Returns: - None if the value is an empty string, otherwise the original value. - """ - if isinstance(value, str) and value.strip() == "": - return None - return value - - -class PluginsSettings(BaseSettings): - """Plugin framework configuration. - - All settings can be overridden via environment variables with the PLUGINS_ prefix. - For example: PLUGINS_ENABLED=true, PLUGINS_PLUGIN_TIMEOUT=60, PLUGINS_SKIP_SSL_VERIFY=true - """ - - enabled: bool = Field(default=False, description="Enable the plugin framework") - default_hook_policy: Literal["allow", "deny"] = Field( - default="allow", - description=( - "Default behavior for hooks without an explicit policy: 'allow' accepts all modifications" - " (backwards compatible), 'deny' rejects all. Standard hooks always have explicit policies;" - " this only affects custom hook types. Set to 'deny' for stricter production environments." - ), - ) - config_file: str = Field(default="plugins/config.yaml", description="Path to main plugins configuration file") - log_level: str = Field(default="INFO", description="Logging level for plugin framework components") - skip_ssl_verify: bool = Field( - default=False, - description="Skip SSL certificate verification for plugin HTTP requests. WARNING: Only enable in dev environments with self-signed certificates.", - ) - ssrf_protection_enabled: bool = Field( - default=True, - description=( - "Enable SSRF protection for plugin endpoint URLs. Blocks private/reserved IP ranges" - " (10.x, 172.16.x, 192.168.x, 127.x, 169.254.x). Disable for development or sidecar" - " plugin configurations that use private IPs." - ), - ) - - # Plugin executor settings - plugin_timeout: int = Field(default=30, description="Plugin execution timeout in seconds") - fail_on_plugin_error: bool = Field( - default=False, - description=( - "Globally halt the pipeline on any plugin error. Superseded by per-plugin on_error;" - " prefer setting on_error: fail on individual plugins for finer control." - ), - ) - execution_pool: int = Field( - default=10, - description="Maximum number of concurrent background tasks. Unlimited if None.", - ) - - # HTTP client settings - httpx_max_connections: int = Field( - default=200, description="Maximum total concurrent HTTP connections for plugin requests" - ) - httpx_max_keepalive_connections: int = Field( - default=100, description="Maximum idle keepalive connections to retain (typically 50%% of max_connections)" - ) - httpx_keepalive_expiry: float = Field( - default=30.0, description="Seconds before idle keepalive connections are closed" - ) - httpx_connect_timeout: float = Field( - default=5.0, description="Timeout in seconds for establishing new connections (5s for LAN, increase for WAN)" - ) - httpx_read_timeout: float = Field( - default=120.0, description="Timeout in seconds for reading response data (set high for slow MCP tool calls)" - ) - httpx_write_timeout: float = Field(default=30.0, description="Timeout in seconds for writing request data") - httpx_pool_timeout: float = Field( - default=10.0, description="Timeout in seconds waiting for a connection from the pool (fail fast on exhaustion)" - ) - - # CLI settings - cli_completion: bool = Field(default=False, description="Enable shell auto-completion for the mcpplugins CLI") - cli_markup_mode: Literal["markdown", "rich", "disabled"] | None = Field( - default=None, description="Markup renderer for CLI output (rich, markdown, or disabled)" - ) - - # MCP client mTLS settings - client_mtls_certfile: str | None = Field(default=None, description="Path to PEM client certificate for mTLS") - client_mtls_keyfile: str | None = Field(default=None, description="Path to PEM client private key for mTLS") - client_mtls_ca_bundle: str | None = Field( - default=None, description="Path to CA bundle for client certificate verification" - ) - client_mtls_keyfile_password: SecretStr | None = Field( - default=None, description="Password for encrypted client private key" - ) - client_mtls_verify: bool | None = Field(default=None, description="Verify the upstream server certificate") - client_mtls_check_hostname: bool | None = Field(default=None, description="Enable hostname verification") - - # MCP server SSL settings - server_ssl_keyfile: str | None = Field(default=None, description="Path to PEM server private key") - server_ssl_certfile: str | None = Field(default=None, description="Path to PEM server certificate") - server_ssl_ca_certs: str | None = Field(default=None, description="Path to CA certificates for client verification") - server_ssl_keyfile_password: SecretStr | None = Field( - default=None, description="Password for encrypted server private key" - ) - server_ssl_cert_reqs: int | None = Field( - default=None, description="Client certificate requirement (0=NONE, 1=OPTIONAL, 2=REQUIRED)" - ) - - # MCP server settings - server_host: str | None = Field(default=None, description="MCP server host to bind to") - server_port: int | None = Field(default=None, description="MCP server port to bind to") - server_uds: str | None = Field(default=None, description="Unix domain socket path for MCP streamable HTTP") - server_ssl_enabled: bool | None = Field(default=None, description="Enable SSL/TLS for the MCP server") - - # MCP runtime settings - config_path: str | None = Field(default=None, description="Path to plugin configuration file for external servers") - transport: str | None = Field(default=None, description="Transport type for external MCP server (http, stdio)") - - # gRPC client mTLS settings - grpc_client_mtls_certfile: str | None = Field( - default=None, description="Path to PEM client certificate for gRPC mTLS" - ) - grpc_client_mtls_keyfile: str | None = Field( - default=None, description="Path to PEM client private key for gRPC mTLS" - ) - grpc_client_mtls_ca_bundle: str | None = Field( - default=None, description="Path to CA bundle for gRPC client verification" - ) - grpc_client_mtls_keyfile_password: SecretStr | None = Field( - default=None, description="Password for encrypted gRPC client private key" - ) - grpc_client_mtls_verify: bool | None = Field( - default=None, description="Verify the gRPC upstream server certificate" - ) - - # gRPC server SSL settings - grpc_server_ssl_keyfile: str | None = Field(default=None, description="Path to PEM gRPC server private key") - grpc_server_ssl_certfile: str | None = Field(default=None, description="Path to PEM gRPC server certificate") - grpc_server_ssl_ca_certs: str | None = Field( - default=None, description="Path to CA certificates for gRPC client verification" - ) - grpc_server_ssl_keyfile_password: SecretStr | None = Field( - default=None, description="Password for encrypted gRPC server private key" - ) - grpc_server_ssl_client_auth: str | None = Field( - default=None, description="gRPC client certificate requirement (none, optional, require)" - ) - - # gRPC server settings - grpc_server_host: str | None = Field(default=None, description="gRPC server host to bind to") - grpc_server_port: int | None = Field(default=None, description="gRPC server port to bind to") - grpc_server_uds: str | None = Field(default=None, description="Unix domain socket path for gRPC server") - grpc_server_ssl_enabled: bool | None = Field(default=None, description="Enable SSL/TLS for the gRPC server") - - # Unix socket settings - unix_socket_path: str | None = Field( - default=None, - description="Path to the Unix domain socket", - validation_alias=AliasChoices("PLUGINS_UNIX_SOCKET_PATH", "UNIX_SOCKET_PATH"), - ) - - @field_validator( - "client_mtls_verify", - "client_mtls_check_hostname", - "server_ssl_cert_reqs", - "server_port", - "server_ssl_enabled", - "grpc_client_mtls_verify", - "grpc_server_port", - "grpc_server_ssl_enabled", - mode="before", - ) - @classmethod - def empty_string_to_none(cls, value: Any) -> Any: - """Delegate to shared validator. - - Args: - value: The raw field value from environment or input. - - Returns: - The original value, or None if the value was an empty string. - """ - return _empty_string_to_none(value) - - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsEnabledSettings(BaseSettings): - """Lightweight settings model for reading PLUGINS_ENABLED only.""" - - enabled: bool = False - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsConfigPathSettings(BaseSettings): - """Lightweight settings model for reading PLUGINS_CONFIG_PATH only.""" - - config_path: str | None = None - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsStartupSettings(BaseSettings): - """Lightweight settings for fields read during gateway startup. - - Reads only ``config_file``, ``plugin_timeout``, ``fail_on_plugin_error``, - and ``execution_pool`` so that malformed unrelated plugin env vars - (e.g. ``PLUGINS_SERVER_PORT=abc``) do not prevent the gateway from booting. - """ - - config_file: str = Field(default="plugins/config.yaml") - plugin_timeout: int = 30 - fail_on_plugin_error: bool = False - execution_pool: int = 10 - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsPolicySettings(BaseSettings): - """Lightweight settings model for reading default hook policy only.""" - - default_hook_policy: Literal["allow", "deny"] = "allow" - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsSsrfSettings(BaseSettings): - """Lightweight settings model for reading SSRF protection flag only.""" - - ssrf_protection_enabled: bool = True - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsTransportSettings(BaseSettings): - """Lightweight settings for transport type and Unix socket path.""" - - transport: str | None = None - unix_socket_path: str | None = Field( - default=None, validation_alias=AliasChoices("UNIX_SOCKET_PATH", "PLUGINS_UNIX_SOCKET_PATH") - ) - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsClientMtlsSettings(BaseSettings): - """Lightweight settings for MCP client mTLS configuration.""" - - client_mtls_certfile: str | None = None - client_mtls_keyfile: str | None = None - client_mtls_ca_bundle: str | None = None - client_mtls_keyfile_password: SecretStr | None = None - client_mtls_verify: bool | None = None - client_mtls_check_hostname: bool | None = None - - @field_validator("client_mtls_verify", "client_mtls_check_hostname", mode="before") - @classmethod - def empty_string_to_none(cls, value: Any) -> Any: - """Delegate to shared validator. - - Args: - value: The raw field value from environment or input. - - Returns: - The original value, or None if the value was an empty string. - """ - return _empty_string_to_none(value) - - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsMcpServerSettings(BaseSettings): - """Lightweight settings for MCP server configuration.""" - - server_ssl_keyfile: str | None = None - server_ssl_certfile: str | None = None - server_ssl_ca_certs: str | None = None - server_ssl_keyfile_password: SecretStr | None = None - server_ssl_cert_reqs: int | None = None - server_host: str | None = None - server_port: int | None = None - server_uds: str | None = None - server_ssl_enabled: bool | None = None - - @field_validator("server_ssl_cert_reqs", "server_port", "server_ssl_enabled", mode="before") - @classmethod - def empty_string_to_none(cls, value: Any) -> Any: - """Delegate to shared validator. - - Args: - value: The raw field value from environment or input. - - Returns: - The original value, or None if the value was an empty string. - """ - return _empty_string_to_none(value) - - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsGrpcClientMtlsSettings(BaseSettings): - """Lightweight settings for gRPC client mTLS configuration.""" - - grpc_client_mtls_certfile: str | None = None - grpc_client_mtls_keyfile: str | None = None - grpc_client_mtls_ca_bundle: str | None = None - grpc_client_mtls_keyfile_password: SecretStr | None = None - grpc_client_mtls_verify: bool | None = None - - @field_validator("grpc_client_mtls_verify", mode="before") - @classmethod - def empty_string_to_none(cls, value: Any) -> Any: - """Delegate to shared validator. - - Args: - value: The raw field value from environment or input. - - Returns: - The original value, or None if the value was an empty string. - """ - return _empty_string_to_none(value) - - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsHttpClientSettings(BaseSettings): - """Lightweight settings for HTTP client (httpx) configuration.""" - - skip_ssl_verify: bool = False - httpx_max_connections: int = 200 - httpx_max_keepalive_connections: int = 100 - httpx_keepalive_expiry: float = 30.0 - httpx_connect_timeout: float = 5.0 - httpx_read_timeout: float = 120.0 - httpx_write_timeout: float = 30.0 - httpx_pool_timeout: float = 10.0 - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsCliSettings(BaseSettings): - """Lightweight settings for mcpplugins CLI configuration.""" - - cli_completion: bool = False - cli_markup_mode: Literal["markdown", "rich", "disabled"] | None = None - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -class PluginsGrpcServerSettings(BaseSettings): - """Lightweight settings for gRPC server configuration.""" - - grpc_server_ssl_keyfile: str | None = None - grpc_server_ssl_certfile: str | None = None - grpc_server_ssl_ca_certs: str | None = None - grpc_server_ssl_keyfile_password: SecretStr | None = None - grpc_server_ssl_client_auth: str | None = None - grpc_server_host: str | None = None - grpc_server_port: int | None = None - grpc_server_uds: str | None = None - grpc_server_ssl_enabled: bool | None = None - - @field_validator("grpc_server_port", "grpc_server_ssl_enabled", mode="before") - @classmethod - def empty_string_to_none(cls, value: Any) -> Any: - """Delegate to shared validator. - - Args: - value: The raw field value from environment or input. - - Returns: - The original value, or None if the value was an empty string. - """ - return _empty_string_to_none(value) - - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - -@lru_cache(maxsize=1) -def get_settings() -> PluginsSettings: - """Get cached plugins settings instance. - - Returns: - PluginsSettings: A cached instance of the PluginsSettings class. - - Examples: - >>> settings = get_settings() - >>> isinstance(settings, PluginsSettings) - True - >>> # Second call returns the same cached instance - >>> settings2 = get_settings() - >>> settings is settings2 - True - """ - # Instantiate a fresh Pydantic PluginsSettings object, - # loading from env vars or .env exactly once. - return PluginsSettings() - - -@lru_cache() -def get_enabled_settings() -> PluginsEnabledSettings: - """Get cached lightweight enabled flag settings instance. - - Returns: - PluginsEnabledSettings: A cached instance. - """ - return PluginsEnabledSettings() - - -@lru_cache() -def get_startup_settings() -> PluginsStartupSettings: - """Get cached lightweight startup settings (config_file, plugin_timeout). - - Returns: - PluginsStartupSettings: A cached instance. - """ - return PluginsStartupSettings() - - -@lru_cache() -def get_config_path_settings() -> PluginsConfigPathSettings: - """Get cached lightweight config-path settings instance. - - Returns: - PluginsConfigPathSettings: A cached instance. - """ - return PluginsConfigPathSettings() - - -@lru_cache() -def get_policy_settings() -> PluginsPolicySettings: - """Get cached lightweight policy settings instance. - - Returns: - PluginsPolicySettings: A cached instance. - """ - return PluginsPolicySettings() - - -@lru_cache() -def get_ssrf_settings() -> PluginsSsrfSettings: - """Get cached lightweight SSRF protection settings instance. - - Returns: - PluginsSsrfSettings: A cached instance. - """ - return PluginsSsrfSettings() - - -@lru_cache() -def get_transport_settings() -> PluginsTransportSettings: - """Get cached lightweight transport settings instance. - - Returns: - PluginsTransportSettings: A cached instance. - """ - return PluginsTransportSettings() - - -@lru_cache() -def get_client_mtls_settings() -> PluginsClientMtlsSettings: - """Get cached lightweight MCP client mTLS settings instance. - - Returns: - PluginsClientMtlsSettings: A cached instance. - """ - return PluginsClientMtlsSettings() - - -@lru_cache() -def get_mcp_server_settings() -> PluginsMcpServerSettings: - """Get cached lightweight MCP server settings instance. - - Returns: - PluginsMcpServerSettings: A cached instance. - """ - return PluginsMcpServerSettings() - - -@lru_cache() -def get_grpc_client_mtls_settings() -> PluginsGrpcClientMtlsSettings: - """Get cached lightweight gRPC client mTLS settings instance. - - Returns: - PluginsGrpcClientMtlsSettings: A cached instance. - """ - return PluginsGrpcClientMtlsSettings() - - -@lru_cache() -def get_http_client_settings() -> PluginsHttpClientSettings: - """Get cached lightweight HTTP client settings instance. - - Returns: - PluginsHttpClientSettings: A cached instance. - """ - return PluginsHttpClientSettings() - - -@lru_cache() -def get_cli_settings() -> PluginsCliSettings: - """Get cached lightweight CLI settings instance. - - Returns: - PluginsCliSettings: A cached instance. - """ - return PluginsCliSettings() - - -@lru_cache() -def get_grpc_server_settings() -> PluginsGrpcServerSettings: - """Get cached lightweight gRPC server settings instance. - - Returns: - PluginsGrpcServerSettings: A cached instance. - """ - return PluginsGrpcServerSettings() - - -class LazySettingsWrapper: - """Lazily initialize plugins settings singleton on getattr.""" - - @staticmethod - def _parse_bool(value: str) -> bool: - """Parse common truthy string values. - - Args: - value: The string value to parse. - - Returns: - True if the value represents a truthy string. - """ - return value.strip().lower() in {"1", "true", "yes", "on"} - - @property - def enabled(self) -> bool: - """Access plugin enabled flag with env override support. - - Returns: - True if plugin framework is enabled. - """ - env_flag = os.getenv("PLUGINS_ENABLED") - if env_flag is not None: - return self._parse_bool(env_flag) - return get_enabled_settings().enabled - - @property - def config_file(self) -> str: - """Access config_file without validating full plugin settings. - - Returns: - The plugin configuration file path. - """ - return get_startup_settings().config_file - - @property - def plugin_timeout(self) -> int: - """Access plugin_timeout without validating full plugin settings. - - Returns: - The plugin execution timeout in seconds. - """ - return get_startup_settings().plugin_timeout - - @property - def fail_on_plugin_error(self) -> bool: - """Access fail_on_plugin_error without validating full plugin settings. - - Returns: - True if the pipeline should halt on any plugin error. - """ - return get_startup_settings().fail_on_plugin_error - - @property - def execution_pool(self) -> int: - """Access execution_pool without validating full plugin settings. - - Returns: - Maximum concurrent tasks. - """ - return get_startup_settings().execution_pool - - @property - def config_path(self) -> str | None: - """Access PLUGINS_CONFIG_PATH without validating full plugin settings. - - Returns: - The config path or None if unset. - """ - return get_config_path_settings().config_path - - @property - def default_hook_policy(self) -> Literal["allow", "deny"]: - """Access default hook policy without validating full plugin settings. - - Returns: - The default hook policy string. - """ - return get_policy_settings().default_hook_policy - - @property - def ssrf_protection_enabled(self) -> bool: - """Access SSRF protection flag without validating full plugin settings. - - Returns: - True if SSRF protection is enabled. - """ - return get_ssrf_settings().ssrf_protection_enabled - - @property - def transport(self) -> str | None: - """Access transport type without validating full plugin settings. - - Returns: - The transport type or None if unset. - """ - return get_transport_settings().transport - - @property - def unix_socket_path(self) -> str | None: - """Access Unix socket path without validating full plugin settings. - - Returns: - The Unix socket path or None if unset. - """ - return get_transport_settings().unix_socket_path - - @property - def cli_completion(self) -> bool: - """Access CLI completion flag without validating full plugin settings. - - Returns: - True if CLI completion is enabled. - """ - return get_cli_settings().cli_completion - - @property - def cli_markup_mode(self) -> Literal["markdown", "rich", "disabled"] | None: - """Access CLI markup mode without validating full plugin settings. - - Returns: - The CLI markup mode or None if unset. - """ - return get_cli_settings().cli_markup_mode - - @staticmethod - def cache_clear() -> None: - """Clear the cached settings instance so the next access re-reads from env.""" - get_settings.cache_clear() - get_enabled_settings.cache_clear() - get_startup_settings.cache_clear() - get_config_path_settings.cache_clear() - get_policy_settings.cache_clear() - get_ssrf_settings.cache_clear() - get_transport_settings.cache_clear() - get_client_mtls_settings.cache_clear() - get_mcp_server_settings.cache_clear() - get_http_client_settings.cache_clear() - get_cli_settings.cache_clear() - get_grpc_client_mtls_settings.cache_clear() - get_grpc_server_settings.cache_clear() - - def __getattr__(self, key: str) -> Any: - """Get the real settings object and forward to it - - Args: - key: The key to fetch from settings - - Returns: - Any: The value of the attribute on the settings - """ - - return getattr(get_settings(), key) - - -settings = LazySettingsWrapper() diff --git a/cpex/framework/utils.py b/cpex/framework/utils.py deleted file mode 100644 index 7eb4a94c..00000000 --- a/cpex/framework/utils.py +++ /dev/null @@ -1,518 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/utils.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Mihai Criveti, Fred Araujo - -Utility module for plugins layer. -This module implements the utility functions associated with -plugins. -""" - -# Standard -import importlib -import logging -from functools import cache -from pathlib import Path -from types import ModuleType -from typing import Any, Optional - -import orjson - -# Third-Party -from fastapi.responses import JSONResponse -from pydantic import BaseModel, ConfigDict - -# First-Party -from cpex.framework.models import GlobalContext, PluginCondition - -logger = logging.getLogger(__name__) - - -class StructuredData(BaseModel): - """Dynamic model that provides attribute access on deserialized dicts. - - When framework payload fields are typed as ``Any``, Pydantic keeps - nested dicts as plain dicts during ``model_validate``. This class - is used by :func:`coerce_nested` to convert those dicts into objects - with attribute-style access, preserving compatibility with plugin - code that expects ``payload.result.messages[0].content.text``. - - Examples: - >>> sd = StructuredData(name="test", value=42) - >>> sd.name - 'test' - >>> sd.model_dump() - {'name': 'test', 'value': 42} - """ - - model_config = ConfigDict(extra="allow") - - -def coerce_messages(v: Any) -> Any: - """Convert nested dicts in a messages list to objects with attribute access. - - Shared validator logic for agent payload ``messages`` fields. - When deserializing from JSON, messages arrive as plain dicts. This - converts each dict to a :class:`StructuredData` so plugin code like - ``payload.messages[0].content.text`` works regardless of the transport. - - Args: - v: The raw value for the ``messages`` field. - - Returns: - The coerced list with attribute access on each element. - """ - if isinstance(v, list): - return [coerce_nested(item) if isinstance(item, dict) else item for item in v] - return v - - -_COERCE_MAX_DEPTH = 20 -_COERCE_MAX_BREADTH = 500 - - -def coerce_nested(v: Any, *, _depth: int = 0) -> Any: - """Recursively convert dicts to :class:`StructuredData` for attribute access. - - Already-constructed Pydantic models (e.g. a real ``PromptResult`` - passed by the gateway) are returned as-is. Depth is capped at - ``_COERCE_MAX_DEPTH`` and breadth (keys per dict / items per list) - at ``_COERCE_MAX_BREADTH`` to guard against resource exhaustion. - - Args: - v: Value to coerce — dict, list, or scalar. - _depth: Internal recursion depth counter (do not set manually). - - Returns: - A ``StructuredData`` (for dicts), a list of coerced items, or - the original value unchanged. - - Examples: - >>> from pydantic import BaseModel - >>> result = coerce_nested({"messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}]}) - >>> result.messages[0].content.text - 'hi' - >>> class Existing(BaseModel): - ... x: int = 1 - >>> coerce_nested(Existing()) is not None - True - """ - if _depth >= _COERCE_MAX_DEPTH: - return v - if isinstance(v, BaseModel): - return v - if isinstance(v, dict): - if len(v) > _COERCE_MAX_BREADTH: - logger.warning( - "coerce_nested: dict has %d keys (limit %d); returning as plain dict", len(v), _COERCE_MAX_BREADTH - ) - return v - return StructuredData(**{k: coerce_nested(val, _depth=_depth + 1) for k, val in v.items()}) - if isinstance(v, list): - if len(v) > _COERCE_MAX_BREADTH: - logger.warning( - "coerce_nested: list has %d items (limit %d); skipping coercion", len(v), _COERCE_MAX_BREADTH - ) - return v - return [coerce_nested(item, _depth=_depth + 1) for item in v] - return v - - -_BLOCKED_MODULE_PREFIXES = ( - "os", - "sys", - "subprocess", - "shutil", - "socket", - "http.server", - "ctypes", - "importlib", - "builtins", - "code", - "codeop", - "compileall", - "runpy", -) - - -@cache # noqa -def import_module(mod_name: str) -> ModuleType: - """Import a module after validating the name is safe for dynamic loading. - - Blocks dangerous stdlib modules that could enable arbitrary code - execution if an attacker controls the plugin ``kind`` field. - - Args: - mod_name: fully qualified module name - - Returns: - A module. - - Raises: - ImportError: If the module name is blocked or contains path traversal. - - Examples: - >>> mod = import_module('cpex.framework.utils') - >>> hasattr(mod, 'import_module') - True - """ - # Block path-traversal-style names and names with dangerous characters - if ".." in mod_name or "/" in mod_name or "\\" in mod_name: - raise ImportError(f"Plugin module name '{mod_name}' contains invalid characters.") - # Block dangerous stdlib modules - for blocked in _BLOCKED_MODULE_PREFIXES: - if mod_name == blocked or mod_name.startswith(blocked + "."): - raise ImportError(f"Plugin module '{mod_name}' is blocked for security reasons.") - return importlib.import_module(mod_name) - - -def parse_class_name(name: str) -> tuple[str, str]: - """Parse a class name into its constituents. - - Args: - name: the qualified class name - - Returns: - A pair containing the qualified class prefix and the class name - - Examples: - >>> parse_class_name('module.submodule.ClassName') - ('module.submodule', 'ClassName') - >>> parse_class_name('SimpleClass') - ('', 'SimpleClass') - >>> parse_class_name('package.Class') - ('package', 'Class') - """ - clslist = name.rsplit(".", 1) - if len(clslist) == 2: - return (clslist[0], clslist[1]) - return ("", name) - - -def normalize_content_type(content_type: str) -> str: - """Extract base content type without parameters. - - Args: - content_type: Raw content type string (e.g., 'application/json; charset=utf-8') - - Returns: - Normalized content type (e.g., 'application/json') - - Examples: - >>> normalize_content_type('application/json; charset=utf-8') - 'application/json' - >>> normalize_content_type('text/html') - 'text/html' - >>> normalize_content_type('TEXT/PLAIN') - 'text/plain' - >>> normalize_content_type('application/json;charset=utf-8') - 'application/json' - >>> normalize_content_type('') - '' - >>> normalize_content_type(' ') - '' - """ - if not isinstance(content_type, str): - return "" - return content_type.split(";", maxsplit=1)[0].strip().lower() - - -def matches(condition: PluginCondition, context: GlobalContext) -> bool: - """Check if GlobalContext conditions match (AND logic). - - All specified fields in the condition must match for this function to return True. - This function uses AND logic - if any field doesn't match, it returns False. - - Args: - condition: the conditions on the plugin that are required for execution. - context: the global context. - - Returns: - True if all specified fields match, False otherwise. - - Examples: - >>> from cpex.framework import GlobalContext, PluginCondition - >>> cond = PluginCondition(server_ids={"srv1", "srv2"}) - >>> ctx = GlobalContext(request_id="req1", server_id="srv1") - >>> matches(cond, ctx) - True - >>> ctx2 = GlobalContext(request_id="req2", server_id="srv3") - >>> matches(cond, ctx2) - False - >>> cond2 = PluginCondition(user_patterns=["admin"]) - >>> ctx3 = GlobalContext(request_id="req3", user="admin_user") - >>> matches(cond2, ctx3) - True - >>> cond3 = PluginCondition(content_types=["application/json"]) - >>> ctx4 = GlobalContext(request_id="req4", content_type="application/json") - >>> matches(cond3, ctx4) - True - >>> ctx5 = GlobalContext(request_id="req5", content_type="application/json; charset=utf-8") - >>> matches(cond3, ctx5) - True - >>> ctx6 = GlobalContext(request_id="req6", content_type="text/plain") - >>> matches(cond3, ctx6) - False - """ - # Check server ID - if condition.server_ids: - if context.server_id not in condition.server_ids: - logger.debug("Server ID mismatch: %s not in %s", context.server_id, condition.server_ids) - return False - logger.debug("Server ID matched: %s", context.server_id) - - # Check tenant ID - if condition.tenant_ids: - if context.tenant_id not in condition.tenant_ids: - logger.debug("Tenant ID mismatch: %s not in %s", context.tenant_id, condition.tenant_ids) - return False - logger.debug("Tenant ID matched: %s", context.tenant_id) - - # Check content types (strict AND logic - fail if content_type is None/empty but condition requires it) - if condition.content_types: - if not context.content_type or not context.content_type.strip(): - logger.debug( - "Content-type mismatch: content_type is None/empty but condition requires: %s", condition.content_types - ) - return False - normalized_request = normalize_content_type(context.content_type) - if normalized_request not in condition.content_types: - return False - logger.debug("Content-type matched: %s", context.content_type) - - # Check user patterns (simple contains check, could be regex) - if condition.user_patterns: - if not context.user: - logger.debug("User pattern mismatch: user is None but patterns required: %s", condition.user_patterns) - return False - - if not any(pattern in context.user for pattern in condition.user_patterns): - logger.debug("User pattern mismatch: %s does not match any of %s", context.user, condition.user_patterns) - return False - logger.debug("User pattern matched: %s", context.user) - - logger.debug("All GlobalContext conditions matched") - return True - - -def get_attr(obj: Any, attr: str, default: Any = "") -> Any: - """Get attribute from object or dictionary with defensive access. - - This utility function provides a consistent way to access attributes - on objects that may be either ORM model instances or plain dictionaries. - - Args: - obj: The object or dictionary to get the attribute from. - attr: The attribute name to retrieve. - default: The default value to return if attribute is not found. - - Returns: - The attribute value, or the default if not found or obj is None. - - Examples: - >>> get_attr({"name": "test"}, "name") - 'test' - >>> get_attr({"name": "test"}, "missing", "default") - 'default' - >>> get_attr(None, "name", "fallback") - 'fallback' - >>> class Obj: - ... name = "obj_name" - >>> get_attr(Obj(), "name") - 'obj_name' - """ - if obj is None: - return default - if hasattr(obj, attr): - return getattr(obj, attr, default) or default - if isinstance(obj, dict): - return obj.get(attr, default) or default - return default - - -def get_matchable_value(payload: Any, hook_type: str) -> Optional[str]: - """Extract the matchable value from a payload based on hook type. - - This function maps hook types to their corresponding payload attributes - that should be used for conditional matching. - - Args: - payload: The payload object (e.g., ToolPreInvokePayload, AgentPreInvokePayload). - hook_type: The hook type identifier. - - Returns: - The matchable value (e.g., tool name, agent ID, resource URI) or None. - - Examples: - >>> from cpex.framework import GlobalContext - >>> from cpex.framework.hooks.tools import ToolPreInvokePayload - >>> payload = ToolPreInvokePayload(name="calculator", args={}) - >>> get_matchable_value(payload, "tool_pre_invoke") - 'calculator' - >>> get_matchable_value(payload, "unknown_hook") - """ - # Mapping: hook_type -> payload attribute name - field_map = { - "tool_pre_invoke": "name", - "tool_post_invoke": "name", - "prompt_pre_fetch": "prompt_id", - "prompt_post_fetch": "prompt_id", - "resource_pre_fetch": "uri", - "resource_post_fetch": "uri", - "agent_pre_invoke": "agent_id", - "agent_post_invoke": "agent_id", - "token_delegate": "target_name", - "identity_resolve": "raw_token", - } - - field_name = field_map.get(hook_type) - if field_name: - return getattr(payload, field_name, None) - return None - - -def payload_matches( - payload: Any, - hook_type: str, - conditions: list[PluginCondition], - context: GlobalContext, -) -> bool: - """Check if a payload matches any of the plugin conditions. - - This function provides generic conditional matching for all hook types. - It checks both GlobalContext conditions (via matches()) and payload-specific - conditions (tools, prompts, resources, agents). - - Args: - payload: The payload object. - hook_type: The hook type identifier. - conditions: List of conditions to check against. - context: The global context. - - Returns: - True if the payload matches any condition or if no conditions are specified. - - Examples: - >>> from cpex.framework import PluginCondition, GlobalContext - >>> from cpex.framework.hooks.tools import ToolPreInvokePayload - >>> payload = ToolPreInvokePayload(name="calculator", args={}) - >>> cond = PluginCondition(tools={"calculator"}) - >>> ctx = GlobalContext(request_id="req1") - >>> payload_matches(payload, "tool_pre_invoke", [cond], ctx) - True - >>> cond2 = PluginCondition(tools={"other_tool"}) - >>> payload_matches(payload, "tool_pre_invoke", [cond2], ctx) - False - >>> payload_matches(payload, "tool_pre_invoke", [], ctx) - True - """ - # Mapping: hook_type -> PluginCondition attribute name - condition_attr_map = { - "tool_pre_invoke": "tools", - "tool_post_invoke": "tools", - "prompt_pre_fetch": "prompts", - "prompt_post_fetch": "prompts", - "resource_pre_fetch": "resources", - "resource_post_fetch": "resources", - "agent_pre_invoke": "agents", - "agent_post_invoke": "agents", - "token_delegate": "tools", - "identity_resolve": "tools", - } - - # If no conditions, match everything - if not conditions: - return True - - # Check each condition (OR logic between conditions) - for condition in conditions: - # First check GlobalContext conditions - if not matches(condition, context): - continue - - # Then check payload-specific conditions - condition_attr = condition_attr_map.get(hook_type) - if condition_attr: - condition_set = getattr(condition, condition_attr, None) - if condition_set: - # Extract the matchable value from the payload - payload_value = get_matchable_value(payload, hook_type) - if payload_value and payload_value not in condition_set: - # Payload value doesn't match this condition's set - continue - - # If we get here, this condition matched - return True - - # No conditions matched - return False - - -class ORJSONResponse(JSONResponse): - """JSON response using orjson for faster serialization. - - Drop-in replacement for FastAPI's default JSONResponse. - The framework already depends on both fastapi and orjson. - - Example: - >>> response = ORJSONResponse(content={"status": "healthy"}) - >>> response.media_type - 'application/json' - """ - - media_type = "application/json" - - def render(self, content: Any) -> bytes: - """Render content to JSON bytes using orjson. - - Args: - content: The content to serialize to JSON. - - Returns: - JSON bytes ready for HTTP response. - """ - return orjson.dumps( - content, - option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY, - ) - - -def find_package_path(package_name: str) -> Path: - """Locate installed package directory using importlib.metadata. - - Args: - package_name: The name of the installed package. - - Returns: - Path to the package directory. - - Raises: - RuntimeError: If package cannot be found. - """ - try: - # Use importlib.metadata for more reliable package discovery - for dist in importlib.metadata.distributions(): - if dist.name == package_name or dist.metadata.get("Name") == package_name: - if dist.files: - # Get the package root from the plugin-manifest.yaml file - for afile in dist.files: - if afile.name == "plugin-manifest.yaml": - located_path = dist.locate_file(afile) - package_path = Path(str(located_path)).parent - logger.debug("Found package %s at %s", package_name, package_path) - return package_path - - # Fallback to importlib.util.find_spec if metadata approach fails - spec = importlib.util.find_spec(package_name) - if spec is not None and spec.origin is not None: - package_path = Path(spec.origin).parent - logger.debug("Found package %s at %s (via find_spec)", package_name, package_path) - return package_path - - raise RuntimeError(f"Could not find installed package: {package_name}") - - except Exception as e: - if isinstance(e, RuntimeError): - raise - raise RuntimeError(f"Error locating package {package_name}: {str(e)}") from e diff --git a/cpex/framework/validators.py b/cpex/framework/validators.py deleted file mode 100644 index 83c04345..00000000 --- a/cpex/framework/validators.py +++ /dev/null @@ -1,216 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/framework/validators.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Self-contained security validation for the plugin framework. - -Contains only the validation methods actually used by framework models -(MCPClientConfig), with hardcoded defaults to avoid any dependency on -the outer package. - -Examples: - >>> SecurityValidator.validate_url("https://example.com") - 'https://example.com' -""" - -# Standard -import ipaddress -import logging -import re -from re import Pattern -from urllib.parse import urlparse - -# First-Party -from cpex.framework.settings import get_ssrf_settings - -logger = logging.getLogger(__name__) - -# Defaults matching the gateway's SecurityValidator in mcpgateway/common/validators.py. -# Keep these in sync -- test_transport_type_enum_parity guards the enum, -# but these constants are verified by test_security_validator_url_scheme_parity. -_ALLOWED_URL_SCHEMES = ("http://", "https://", "ws://", "wss://") -_MAX_URL_LENGTH = 2048 - -# Dangerous URL protocol patterns (matches gateway's _DANGEROUS_URL_PATTERNS) -_DANGEROUS_URL_PATTERNS: list[Pattern[str]] = [ - re.compile(r"javascript:", re.IGNORECASE), - re.compile(r"data:", re.IGNORECASE), - re.compile(r"vbscript:", re.IGNORECASE), - re.compile(r"about:", re.IGNORECASE), - re.compile(r"chrome:", re.IGNORECASE), - re.compile(r"file:", re.IGNORECASE), - re.compile(r"ftp:", re.IGNORECASE), - re.compile(r"mailto:", re.IGNORECASE), -] - -# HTML/script XSS patterns (matches gateway's DANGEROUS_HTML_PATTERN / DANGEROUS_JS_PATTERN). -# Keep in sync with mcpgateway/config.py validation_dangerous_html_pattern / validation_dangerous_js_pattern. -_DANGEROUS_HTML_PATTERN = re.compile( - r"<(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)\b" - r"|", - re.IGNORECASE, -) -_DANGEROUS_JS_PATTERN = re.compile( - r"(?:^|\s|[\"'`<>=])(javascript:|vbscript:|data:\s*[^,]*[;\s]*(javascript|vbscript)|\bon[a-z]+\s*=|<\s*script\b)", - re.IGNORECASE, -) - -# Private/reserved IPv4 networks blocked for SSRF protection -_BLOCKED_NETWORKS = [ - ipaddress.ip_network("10.0.0.0/8"), - ipaddress.ip_network("172.16.0.0/12"), - ipaddress.ip_network("192.168.0.0/16"), - ipaddress.ip_network("127.0.0.0/8"), - ipaddress.ip_network("169.254.0.0/16"), # Link-local / cloud metadata -] - - -class SecurityValidator: - """Security validator for the plugin framework. - - Mirrors the SSRF-hardening checks from the gateway's SecurityValidator - without depending on mcpgateway.config.settings. - - Examples: - >>> SecurityValidator.validate_url("https://example.com") - 'https://example.com' - """ - - @staticmethod - def validate_url(value: str, field_name: str = "URL") -> str: - """Validate URLs for allowed schemes, SSRF protection, and safe structure. - - Credentials, IPv6, dangerous protocols, CRLF injection, spaces in - domain, and port range are always enforced. SSRF IP-range blocking - (private/reserved networks) is gated by the ``ssrf_protection_enabled`` - plugin setting. - - Args: - value: URL string to validate. - field_name: Name of the field being validated (for error messages). - - Returns: - The validated URL string. - - Raises: - ValueError: If the URL is empty, too long, uses a disallowed - scheme, contains credentials, targets a blocked IP (when SSRF - protection is enabled), or is structurally invalid. - - Examples: - >>> SecurityValidator.validate_url("https://example.com") - 'https://example.com' - >>> SecurityValidator.validate_url("https://example.com:9000/sse") - 'https://example.com:9000/sse' - >>> SecurityValidator.validate_url("") - Traceback (most recent call last): - ... - ValueError: URL cannot be empty - >>> SecurityValidator.validate_url("ftp://example.com") - Traceback (most recent call last): - ... - ValueError: URL must start with one of: http://, https://, ws://, wss:// - >>> SecurityValidator.validate_url("https://user:pass@example.com/") - Traceback (most recent call last): - ... - ValueError: URL contains credentials which are not allowed - >>> SecurityValidator.validate_url("https://[::1]:8080/") - Traceback (most recent call last): - ... - ValueError: URL contains IPv6 address which is not supported - >>> SecurityValidator.validate_url("https://0.0.0.0/") - Traceback (most recent call last): - ... - ValueError: URL contains invalid IP address (0.0.0.0) - >>> SecurityValidator.validate_url("https://example.com/") - Traceback (most recent call last): - ... - ValueError: URL contains HTML tags that may cause security issues - """ - if not value: - raise ValueError(f"{field_name} cannot be empty") - - if len(value) > _MAX_URL_LENGTH: - raise ValueError(f"{field_name} exceeds maximum length of {_MAX_URL_LENGTH}") - - if not any(value.lower().startswith(scheme) for scheme in _ALLOWED_URL_SCHEMES): - raise ValueError(f"{field_name} must start with one of: {', '.join(_ALLOWED_URL_SCHEMES)}") - - # Block dangerous URL patterns - for pattern in _DANGEROUS_URL_PATTERNS: - if pattern.search(value): - raise ValueError(f"{field_name} contains unsupported or potentially dangerous protocol") - - # Block IPv6 URLs - if "[" in value or "]" in value: - raise ValueError(f"{field_name} contains IPv6 address which is not supported") - - # Block CRLF injection - if "\r" in value or "\n" in value: - raise ValueError(f"{field_name} contains line breaks which are not allowed") - - # Block spaces in domain (but allow in query string) - if " " in value.split("?", maxsplit=1)[0]: - raise ValueError(f"{field_name} contains spaces which are not allowed in URLs") - - try: - result = urlparse(value) - if not all([result.scheme, result.netloc]): - raise ValueError(f"{field_name} is not a valid URL") - - # Block credentials in URL - if result.username or result.password: - raise ValueError(f"{field_name} contains credentials which are not allowed") - - # Validate port number - if result.port is not None: - if result.port < 1 or result.port > 65535: - raise ValueError(f"{field_name} contains invalid port number") - - # SSRF protection: block dangerous IP addresses (always block 0.0.0.0) - hostname = result.hostname - if hostname: - if hostname == "0.0.0.0": # nosec B104 - raise ValueError(f"{field_name} contains invalid IP address (0.0.0.0)") - - # Gate private/reserved IP blocking on plugin-specific settings. - if get_ssrf_settings().ssrf_protection_enabled: - try: - addr = ipaddress.ip_address(hostname) - for network in _BLOCKED_NETWORKS: - if addr in network: - raise ValueError( - f"{field_name} contains IP address blocked by SSRF protection ({hostname})" - ) - except ValueError as ip_err: - if "blocked by SSRF" in str(ip_err): - raise - # Not a valid IP — it's a hostname, which is fine - - # Block HTML tags and script/event-handler patterns in URL - if _DANGEROUS_HTML_PATTERN.search(value): - raise ValueError(f"{field_name} contains HTML tags that may cause security issues") - if _DANGEROUS_JS_PATTERN.search(value): - raise ValueError(f"{field_name} contains script patterns that may cause security issues") - - except ValueError: - raise - except Exception: - raise ValueError(f"{field_name} is not a valid URL") - - return value - - -def validate_plugin_url(value: str, field_name: str = "URL") -> str: - """Plugin framework URL validation entry point. - - Args: - value: The URL string to validate. - field_name: Descriptive name for error messages. - - Returns: - The validated URL string. - """ - return SecurityValidator.validate_url(value, field_name) diff --git a/cpex/templates/external/cookiecutter.json b/cpex/templates/external/cookiecutter.json deleted file mode 100644 index 1016c1e9..00000000 --- a/cpex/templates/external/cookiecutter.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugin_name": "MyFilter", - "plugin_slug": "{{ cookiecutter.plugin_name|lower|replace(' ', '_')|replace('-', '_') }}", - "version": "0.1.0", - "author": "Your Name", - "email": "your@email.com", - "description": "A filter plugin" -} diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/.dockerignore b/cpex/templates/external/{{cookiecutter.plugin_slug}}/.dockerignore deleted file mode 100644 index e9a71f90..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/.dockerignore +++ /dev/null @@ -1,363 +0,0 @@ -# syntax=docker/dockerfile:1 -#---------------------------------------------------------------------- -# Docker Build Context Optimization -# -# This .dockerignore file excludes unnecessary files from the Docker -# build context to improve build performance and security. -#---------------------------------------------------------------------- - -#---------------------------------------------------------------------- -# 1. Development and source directories (not needed in production) -#---------------------------------------------------------------------- -agent_runtimes/ -charts/ -deployment/ -docs/ -deployment/k8s/ -mcp-servers/ -tests/ -test/ -attic/ -*.md -.benchmarks/ - -# Development environment directories -.devcontainer/ -.github/ -.vscode/ -.idea/ - -#---------------------------------------------------------------------- -# 2. Version control -#---------------------------------------------------------------------- -.git/ -.gitignore -.gitattributes -.gitmodules - -#---------------------------------------------------------------------- -# 3. Python build artifacts and caches -#---------------------------------------------------------------------- -# Byte-compiled files -__pycache__/ -*.py[cod] -*.pyc -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST -.wily/ - -# PyInstaller -*.manifest -*.spec - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ -.pytype/ - -# Cython debug symbols -cython_debug/ - -#---------------------------------------------------------------------- -# 4. Virtual environments -#---------------------------------------------------------------------- -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -.python37/ -.python39/ -.python-version - -# PDM -pdm.lock -.pdm.toml -.pdm-python - -#---------------------------------------------------------------------- -# 5. Package managers and dependencies -#---------------------------------------------------------------------- -# Node.js -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.npm -.yarn - -# pip -pip-log.txt -pip-delete-this-directory.txt - -#---------------------------------------------------------------------- -# 6. Docker and container files (avoid recursive copies) -#---------------------------------------------------------------------- -Dockerfile -Dockerfile.* -Containerfile -Containerfile.* -docker-compose.yml -docker-compose.*.yml -podman-compose*.yaml -.dockerignore - -#---------------------------------------------------------------------- -# 7. IDE and editor files -#---------------------------------------------------------------------- -# JetBrains -.idea/ -*.iml -*.iws -*.ipr - -# VSCode -.vscode/ -*.code-workspace - -# Vim -*.swp -*.swo -*~ - -# Emacs -*~ -\#*\# -.\#* - -# macOS -.DS_Store -.AppleDouble -.LSOverride - -#---------------------------------------------------------------------- -# 8. Build tools and CI/CD configurations -#---------------------------------------------------------------------- -# Testing configurations -.coveragerc -.pylintrc -.flake8 -pytest.ini -tox.ini -.pytest.ini - -# Linting and formatting -.hadolint.yaml -.pre-commit-config.yaml -.pycodestyle -.pyre_configuration -.pyspelling.yaml -.ruff.toml -.shellcheckrc - -# Build configurations -Makefile -setup.cfg -pyproject.toml.bak -MANIFEST.in - -# CI/CD -.travis.* -.gitlab-ci.yml -.circleci/ -.github/ -azure-pipelines.yml -Jenkinsfile - -# Code quality -sonar-code.properties -sonar-project.properties -.scannerwork/ -whitesource.config -.whitesource - -# Other tools -.bumpversion.cfg -.editorconfig -mypy.ini - -#---------------------------------------------------------------------- -# 9. Application runtime files (should not be in image) -#---------------------------------------------------------------------- -# Databases -*.db -*.sqlite -*.sqlite3 -mcp.db -db.sqlite3 - -# Logs -*.log -logs/ -log/ - -# Certificates and secrets -certs/ -*.pem -*.key -*.crt -*.csr -.env -.env.* - -# Generated files -public/ -static/ -media/ - -# Application instances -instance/ -local_settings.py - -#---------------------------------------------------------------------- -# 10. Framework-specific files -#---------------------------------------------------------------------- -# Django -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal -media/ - -# Flask -instance/ -.webassets-cache - -# Scrapy -.scrapy - -# Sphinx documentation -docs/_build/ -docs/build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints -*.ipynb - -# IPython -profile_default/ -ipython_config.py - -# celery -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -#---------------------------------------------------------------------- -# 11. Backup and temporary files -#---------------------------------------------------------------------- -*.bak -*.backup -*.tmp -*.temp -*.orig -*.rej -.backup/ -backup/ -tmp/ -temp/ - -#---------------------------------------------------------------------- -# 12. Documentation and miscellaneous -#---------------------------------------------------------------------- -*.md -!README.md -LICENSE -CHANGELOG -AUTHORS -CONTRIBUTORS -TODO -TODO.md -DEVELOPING.md -CONTRIBUTING.md - -# Spelling -.spellcheck-en.txt -*.dic - -# Shell scripts (if not needed in container) -test.sh -scripts/test/ -scripts/dev/ - -#---------------------------------------------------------------------- -# 13. OS-specific files -#---------------------------------------------------------------------- -# Windows -Thumbs.db -ehthumbs.db -Desktop.ini -$RECYCLE.BIN/ - -# Linux -*~ -.fuse_hidden* -.directory -.Trash-* -.nfs* - -#---------------------------------------------------------------------- -# End of .dockerignore -#---------------------------------------------------------------------- diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/.env.template b/cpex/templates/external/{{cookiecutter.plugin_slug}}/.env.template deleted file mode 100644 index eab96140..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/.env.template +++ /dev/null @@ -1,126 +0,0 @@ -##################################### -# Plugins Settings -##################################### - -# Enable the plugin framework -PLUGINS_ENABLED=true - -# Enable auto-completion for plugins CLI -PLUGINS_CLI_COMPLETION=false - -# Set markup mode for plugins CLI -# Valid options: -# rich: use rich markup -# markdown: allow markdown in help strings -# disabled: disable markup -# If unset (commented out), uses "rich" if rich is detected, otherwise disables it. -PLUGINS_CLI_MARKUP_MODE=rich - -# Configuration path for plugin loader -PLUGINS_CONFIG=./resources/plugins/config.yaml - -# Transport type - 'http', 'stdio', 'grpc', or 'unix' -# - http: MCP over HTTP (default, works with any client) -# - stdio: MCP over stdio (for subprocess-based plugins) -# - grpc: gRPC transport (high-performance, requires grpc extras) -# - unix: Unix socket transport (high-performance local IPC, requires grpc extras) -PLUGINS_TRANSPORT=http - -# Server address (default: 0.0.0.0) - HTTP mode only -# PLUGINS_SERVER_HOST=0.0.0.0 - -# Server port (default: 8000) - HTTP mode only -# PLUGINS_SERVER_PORT=8000 - -# Enable SSL/TLS (true/false) - HTTP mode only -# PLUGINS_SERVER_SSL_ENABLED=false - -# Path to server private key - HTTP mode only -# PLUGINS_SERVER_SSL_KEYFILE= - -# Path to server certificate - HTTP mode only -# PLUGINS_SERVER_SSL_CERTFILE= - -# Path to CA bundle for client verification - HTTP mode only -# PLUGINS_SERVER_SSL_CA_CERTS= - -# Client cert requirement (0=NONE, 1=OPTIONAL, 2=REQUIRED) - HTTP mode only -# PLUGINS_SERVER_SSL_CERT_REQS= - -##################################### -# MCP External Plugin Server - mTLS Configuration -##################################### - -# Enable SSL/TLS for external plugin MCP server -# Options: true, false (default) -# When true: Enables HTTPS and optionally mTLS for the plugin MCP server -MCP_SSL_ENABLED=false - -# SSL/TLS Certificate Files -# Path to server private key (required when MCP_SSL_ENABLED=true) -# Generate with: openssl genrsa -out certs/mcp/server.key 2048 -# MCP_SSL_KEYFILE=certs/mcp/server.key - -# Path to server certificate (required when MCP_SSL_ENABLED=true) -# Generate with: openssl req -new -x509 -key certs/mcp/server.key -out certs/mcp/server.crt -days 365 -# MCP_SSL_CERTFILE=certs/mcp/server.crt - -# Optional password for encrypted private key -# MCP_SSL_KEYFILE_PASSWORD= - -# mTLS (Mutual TLS) Configuration -# Client certificate verification mode: -# 0 (CERT_NONE): No client certificate required - standard TLS (default) -# 1 (CERT_OPTIONAL): Client certificate optional - validate if provided -# 2 (CERT_REQUIRED): Client certificate required - full mTLS -# Default: 0 (standard TLS without client verification) -MCP_SSL_CERT_REQS=0 - -# CA certificate bundle for verifying client certificates -# Required when MCP_SSL_CERT_REQS=1 or MCP_SSL_CERT_REQS=2 -# Can be a single CA file or a bundle containing multiple CAs -# MCP_SSL_CA_CERTS=certs/mcp/ca.crt - -##################################### -# gRPC Plugin Server Configuration -# (only used when PLUGINS_TRANSPORT=grpc) -##################################### - -# gRPC server host (default: 0.0.0.0) -# PLUGINS_GRPC_SERVER_HOST=0.0.0.0 - -# gRPC server port (default: 50051) -# PLUGINS_GRPC_SERVER_PORT=50051 - -# gRPC Unix domain socket path (alternative to host:port) -# When set, host/port are ignored. Provides highest local performance. -# TLS is not supported with Unix domain sockets. -# PLUGINS_GRPC_SERVER_UDS=/var/run/grpc-plugin.sock - -# gRPC TLS/mTLS Configuration (not supported when using UDS) -# Enable TLS (required to enable TLS) -# PLUGINS_GRPC_SERVER_SSL_ENABLED=true - -# Path to server certificate (required when SSL_ENABLED=true) -# PLUGINS_GRPC_SERVER_SSL_CERTFILE=certs/grpc/server.pem - -# Path to server private key (required when SSL_ENABLED=true) -# PLUGINS_GRPC_SERVER_SSL_KEYFILE=certs/grpc/server-key.pem - -# Path to CA bundle for client certificate verification (enables mTLS) -# PLUGINS_GRPC_SERVER_SSL_CA_CERTS=certs/grpc/ca.pem - -# Client certificate requirement mode: -# none: No client certificate required (TLS only) -# optional: Client certificate validated if provided -# require: Client certificate required (full mTLS) -# Default: require (when CA bundle is provided) -# PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH=require - -##################################### -# Unix Socket Plugin Server Configuration -# (only used when PLUGINS_TRANSPORT=unix) -##################################### - -# Path to Unix domain socket file -# PLUGINS_UNIX_SOCKET_PATH=/tmp/mcpgateway-plugins.sock diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/.ruff.toml b/cpex/templates/external/{{cookiecutter.plugin_slug}}/.ruff.toml deleted file mode 100644 index 443a275d..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/.ruff.toml +++ /dev/null @@ -1,63 +0,0 @@ -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".ipynb_checkpoints", - ".mypy_cache", - ".nox", - ".pants.d", - ".pyenv", - ".pytest_cache", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - ".vscode", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "site-packages", - "venv", - "docs", - "test" -] - -# 200 line length -line-length = 200 -indent-width = 4 - -# Assume Python 3.11 -target-version = "py311" - -[lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F"] -ignore = [] - -# Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -[format] -# Like Black, use double quotes for strings. -quote-style = "double" - -# Like Black, indent with spaces, rather than tabs. -indent-style = "space" - -# Like Black, respect magic trailing commas. -skip-magic-trailing-comma = false - -# Like Black, automatically detect the appropriate line ending. -line-ending = "auto" diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/Containerfile b/cpex/templates/external/{{cookiecutter.plugin_slug}}/Containerfile deleted file mode 100644 index 4a9add00..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/Containerfile +++ /dev/null @@ -1,58 +0,0 @@ -# syntax=docker/dockerfile:1.7 -ARG UBI=python-312-minimal - -FROM registry.access.redhat.com/ubi9/${UBI} AS builder - -ARG PYTHON_VERSION=3.12 - -ARG VERSION - -ENV APP_HOME=/app - -# Transport type: 'http', 'stdio', 'grpc', 'unix' (default: http) -ENV PLUGINS_TRANSPORT=http - -# Set to 'true' to install gRPC dependencies (required for grpc/unix transports) -ARG INSTALL_GRPC=false - -USER 0 - -# Image pre-requisites -RUN INSTALL_PKGS="git make gcc gcc-c++ python${PYTHON_VERSION}-devel" && \ - microdnf -y --setopt=tsflags=nodocs --setopt=install_weak_deps=0 install $INSTALL_PKGS && \ - microdnf -y clean all --enablerepo='*' - -# Setup alias from HOME to APP_HOME -RUN mkdir -p ${APP_HOME} && \ - chown -R 1001:0 ${APP_HOME} && \ - ln -s ${HOME} ${APP_HOME} && \ - mkdir -p ${HOME}/resources/config && \ - chown -R 1001:0 ${HOME}/resources/config - -USER 1001 - -# Install plugin package -COPY . . - -# Install base package, optionally with gRPC extras -RUN pip install --no-cache-dir uv && \ - if [ "${INSTALL_GRPC}" = "true" ]; then \ - echo "Installing with gRPC support..." && \ - python -m uv pip install ".[grpc]"; \ - else \ - echo "Installing without gRPC support..." && \ - python -m uv pip install .; \ - fi - -# Make default cache directory writable -RUN mkdir -p -m 0776 ${HOME}/.cache - -# Update labels -LABEL maintainer="ContextForge Team" \ - name="mcp/mcppluginserver" \ - version="${VERSION}" \ - url="https://github.com/IBM/mcp-context-forge" \ - description="MCP Plugin Server for ContextForge" - -# App entrypoint -ENTRYPOINT ["sh", "-c", "${HOME}/run-server.sh"] diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/MANIFEST.in b/cpex/templates/external/{{cookiecutter.plugin_slug}}/MANIFEST.in deleted file mode 100644 index 82f70b9f..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/MANIFEST.in +++ /dev/null @@ -1,65 +0,0 @@ -# MANIFEST.in - source-distribution contents for {{ cookiecutter.plugin_slug }} - -# 1 Core project files that SDists/Wheels should always carry -include LICENSE -include README.md -include pyproject.toml -include Containerfile - -# 2 Top-level config, examples and helper scripts -include *.py -include *.md -include *.example -include *.lock -include *.properties -include *.toml -include *.yaml -include *.yml -include *.json -include *.sh -include *.txt -recursive-include tests/async *.py -recursive-include tests/async *.yaml - -# 3 Tooling/lint configuration dot-files (explicit so they're not lost) -include .env.make -include .interrogaterc -include .jshintrc -include whitesource.config -include .darglint -include .dockerignore -include .flake8 -include .htmlhintrc -include .pycodestyle -include .pylintrc -include .whitesource -include .coveragerc -# include .gitignore # purely optional but many projects ship it -include .bumpversion.cfg -include .yamllint -include .editorconfig -include .snyk - -# 4 Runtime data that lives *inside* the package at import time -recursive-include resources/plugins *.yaml -recursive-include {{ cookiecutter.plugin_slug }} *.yaml - -# 5 (Optional) include MKDocs-based docs in the sdist -# graft docs - -# 6 Never publish caches, compiled or build outputs, deployment, agent_runtimes, etc. -global-exclude __pycache__ *.py[cod] *.so *.dylib -prune build -prune dist -prune .eggs -prune *.egg-info -prune charts -prune k8s -prune .devcontainer -exclude CLAUDE.* -exclude llms-full.txt - -# Exclude deployment, mcp-servers and agent_runtimes -prune deployment -prune mcp-servers -prune agent_runtimes diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/Makefile b/cpex/templates/external/{{cookiecutter.plugin_slug}}/Makefile deleted file mode 100644 index 132048e5..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/Makefile +++ /dev/null @@ -1,494 +0,0 @@ - -REQUIRED_BUILD_BINS := uv - -SHELL := /bin/bash -.SHELLFLAGS := -eu -o pipefail -c - -# Project variables -PACKAGE_NAME = {{ cookiecutter.plugin_slug }} -PROJECT_NAME = {{ cookiecutter.plugin_slug }} -TARGET ?= {{ cookiecutter.plugin_slug }} - -# Virtual-environment variables -VENVS_DIR ?= $(HOME)/.venv -VENV_DIR ?= $(VENVS_DIR)/$(PROJECT_NAME) - -# ============================================================================= -# Linters -# ============================================================================= - -black: - @echo "black $(TARGET)..." && $(VENV_DIR)/bin/black -l 200 $(TARGET) - -black-check: - @echo "black --check $(TARGET)..." && $(VENV_DIR)/bin/black -l 200 --check --diff $(TARGET) - -ruff: - @echo "ruff $(TARGET)..." && $(VENV_DIR)/bin/ruff check $(TARGET) && $(VENV_DIR)/bin/ruff format $(TARGET) - -ruff-check: - @echo "ruff check $(TARGET)..." && $(VENV_DIR)/bin/ruff check $(TARGET) - -ruff-fix: - @echo "ruff check --fix $(TARGET)..." && $(VENV_DIR)/bin/ruff check --fix $(TARGET) - -ruff-format: - @echo "ruff format $(TARGET)..." && $(VENV_DIR)/bin/ruff format $(TARGET) - -# ============================================================================= -# Container runtime configuration and operations -# ============================================================================= - -# Container resource limits -CONTAINER_MEMORY = 2048m -CONTAINER_CPUS = 2 - -# Auto-detect container runtime if not specified - DEFAULT TO DOCKER -CONTAINER_RUNTIME ?= $(shell command -v docker >/dev/null 2>&1 && echo docker || echo podman) - -# Alternative: Always default to docker unless explicitly overridden -# CONTAINER_RUNTIME ?= docker - -# Container port -CONTAINER_PORT ?= 8000 -CONTAINER_INTERNAL_PORT ?= 8000 - -print-runtime: - @echo Using container runtime: $(CONTAINER_RUNTIME) - -# Base image name (without any prefix) -IMAGE_BASE ?= mcpgateway/$(PROJECT_NAME) -IMAGE_TAG ?= latest - -# Handle runtime-specific image naming -ifeq ($(CONTAINER_RUNTIME),podman) - # Podman adds localhost/ prefix for local builds - IMAGE_LOCAL := localhost/$(IMAGE_BASE):$(IMAGE_TAG) - IMAGE_LOCAL_DEV := localhost/$(IMAGE_BASE)-dev:$(IMAGE_TAG) - IMAGE_PUSH := $(IMAGE_BASE):$(IMAGE_TAG) -else - # Docker doesn't add prefix - IMAGE_LOCAL := $(IMAGE_BASE):$(IMAGE_TAG) - IMAGE_LOCAL_DEV := $(IMAGE_BASE)-dev:$(IMAGE_TAG) - IMAGE_PUSH := $(IMAGE_BASE):$(IMAGE_TAG) -endif - -print-image: - @echo "Container Runtime: $(CONTAINER_RUNTIME)" - @echo "Using image: $(IMAGE_LOCAL)" - @echo "Development image: $(IMAGE_LOCAL_DEV)" - @echo "Push image: $(IMAGE_PUSH)" - -{% raw %} - -# Function to get the actual image name as it appears in image list -define get_image_name -$(shell $(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | grep -E "(localhost/)?$(IMAGE_BASE):$(IMAGE_TAG)" | head -1) -endef - -# Function to normalize image name for operations -define normalize_image -$(if $(findstring localhost/,$(1)),$(1),$(if $(filter podman,$(CONTAINER_RUNTIME)),localhost/$(1),$(1))) -endef - -# Containerfile to use (can be overridden) -#CONTAINER_FILE ?= Containerfile -CONTAINER_FILE ?= $(shell [ -f "Containerfile" ] && echo "Containerfile" || echo "Dockerfile") - -# Define COMMA for the conditional Z flag -COMMA := , - -container-info: - @echo "Container Runtime Configuration" - @echo "Runtime: $(CONTAINER_RUNTIME)" - @echo "Base Image: $(IMAGE_BASE)" - @echo "Tag: $(IMAGE_TAG)" - @echo "Local Image: $(IMAGE_LOCAL)" - @echo "Push Image: $(IMAGE_PUSH)" - @echo "Actual Image: $(call get_image_name)" - @echo "Container File: $(CONTAINER_FILE)" - -# Auto-detect platform based on uname -PLATFORM ?= linux/$(shell uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') - -container-build: - @echo "Building with $(CONTAINER_RUNTIME) for platform $(PLATFORM)..." - $(CONTAINER_RUNTIME) build \ - --platform=$(PLATFORM) \ - -f $(CONTAINER_FILE) \ - --tag $(IMAGE_BASE):$(IMAGE_TAG) \ - . - @echo "Built image: $(call get_image_name)" - $(CONTAINER_RUNTIME) images $(IMAGE_BASE):$(IMAGE_TAG) - -container-run: container-check-image - @echo "Running with $(CONTAINER_RUNTIME)..." - -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true - -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true - $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ - --env-file=.env \ - -p $(CONTAINER_PORT):$(CONTAINER_INTERNAL_PORT) \ - --restart=always \ - --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ - --health-cmd="curl --fail http://localhost:$(CONTAINER_INTERNAL_PORT)/health || exit 1" \ - --health-interval=1m --health-retries=3 \ - --health-start-period=30s --health-timeout=10s \ - -d $(call get_image_name) - @sleep 2 - @echo "Container started" - @echo "Health check status:" - @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health check configured" - -container-run-host: container-check-image - @echo "Running with $(CONTAINER_RUNTIME)..." - -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true - -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true - $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ - --env-file=.env \ - --network=host \ - -p $(CONTAINER_PORT):$(CONTAINER_INTERNAL_PORT) \ - --restart=always \ - --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ - --health-cmd="curl --fail http://localhost:$(CONTAINER_INTERNAL_PORT)/health || exit 1" \ - --health-interval=1m --health-retries=3 \ - --health-start-period=30s --health-timeout=10s \ - -d $(call get_image_name) - @sleep 2 - @echo "Container started" - @echo "Health check status:" - @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health check configured" - -container-push: container-check-image - @echo "Preparing to push image..." - @# For Podman, we need to remove localhost/ prefix for push - @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ - actual_image=$$($(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | grep -E "$(IMAGE_BASE):$(IMAGE_TAG)" | head -1); \ - if echo "$$actual_image" | grep -q "^localhost/"; then \ - echo "Tagging for push (removing localhost/ prefix)..."; \ - $(CONTAINER_RUNTIME) tag "$$actual_image" $(IMAGE_PUSH); \ - fi; \ - fi - $(CONTAINER_RUNTIME) push $(IMAGE_PUSH) - @echo "Pushed: $(IMAGE_PUSH)" - -container-check-image: - @echo "Checking for image..." - @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ - if ! $(CONTAINER_RUNTIME) image exists $(IMAGE_LOCAL) 2>/dev/null && \ - ! $(CONTAINER_RUNTIME) image exists $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null; then \ - echo "Image not found: $(IMAGE_LOCAL)"; \ - echo "Run 'make container-build' first"; \ - exit 1; \ - fi; \ - else \ - if ! $(CONTAINER_RUNTIME) images -q $(IMAGE_LOCAL) 2>/dev/null | grep -q . && \ - ! $(CONTAINER_RUNTIME) images -q $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null | grep -q .; then \ - echo "Image not found: $(IMAGE_LOCAL)"; \ - echo "Run 'make container-build' first"; \ - exit 1; \ - fi; \ - fi - @echo "Image found" - -container-stop: - @echo "Stopping container..." - -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true - -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true - @echo "Container stopped and removed" - -container-logs: - @echo "Streaming logs (Ctrl+C to exit)..." - $(CONTAINER_RUNTIME) logs -f $(PROJECT_NAME) - -container-shell: - @echo "Opening shell in container..." - @if ! $(CONTAINER_RUNTIME) ps -q -f name=$(PROJECT_NAME) | grep -q .; then \ - echo "Container $(PROJECT_NAME) is not running"; \ - echo "Run 'make container-run' first"; \ - exit 1; \ - fi - @$(CONTAINER_RUNTIME) exec -it $(PROJECT_NAME) /bin/bash 2>/dev/null || \ - $(CONTAINER_RUNTIME) exec -it $(PROJECT_NAME) /bin/sh - -container-health: - @echo "Checking container health..." - @if ! $(CONTAINER_RUNTIME) ps -q -f name=$(PROJECT_NAME) | grep -q .; then \ - echo "Container $(PROJECT_NAME) is not running"; \ - exit 1; \ - fi - @echo "Status: $$($(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo 'No health check')" - @echo "Logs:" - @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null || true - -container-build-multi: - @echo "Building multi-architecture image..." - @if [ "$(CONTAINER_RUNTIME)" = "docker" ]; then \ - if ! docker buildx inspect $(PROJECT_NAME)-builder >/dev/null 2>&1; then \ - echo "Creating buildx builder..."; \ - docker buildx create --name $(PROJECT_NAME)-builder; \ - fi; \ - docker buildx use $(PROJECT_NAME)-builder; \ - docker buildx build \ - --platform=linux/amd64,linux/arm64 \ - -f $(CONTAINER_FILE) \ - --tag $(IMAGE_BASE):$(IMAGE_TAG) \ - --push \ - .; \ - elif [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ - echo "Building manifest with Podman..."; \ - $(CONTAINER_RUNTIME) build --platform=linux/amd64,linux/arm64 \ - -f $(CONTAINER_FILE) \ - --manifest $(IMAGE_BASE):$(IMAGE_TAG) \ - .; \ - echo "To push: podman manifest push $(IMAGE_BASE):$(IMAGE_TAG)"; \ - else \ - echo "Multi-arch builds require Docker buildx or Podman"; \ - exit 1; \ - fi - -# Helper targets for debugging image issues -image-list: - @echo "Images matching $(IMAGE_BASE):" - @$(CONTAINER_RUNTIME) images --format "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Created}}\t{{.Size}}" | \ - grep -E "(IMAGE|$(IMAGE_BASE))" || echo "No matching images found" - -image-clean: - @echo "Removing all $(IMAGE_BASE) images..." - @$(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | \ - grep -E "(localhost/)?$(IMAGE_BASE)" | \ - xargs $(XARGS_FLAGS) $(CONTAINER_RUNTIME) rmi -f 2>/dev/null - @echo "Images cleaned" - -# Fix image naming issues -image-retag: - @echo "Retagging images for consistency..." - @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ - if $(CONTAINER_RUNTIME) image exists $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null; then \ - $(CONTAINER_RUNTIME) tag $(IMAGE_BASE):$(IMAGE_TAG) $(IMAGE_LOCAL) 2>/dev/null || true; \ - fi; \ - else \ - if $(CONTAINER_RUNTIME) images -q $(IMAGE_LOCAL) 2>/dev/null | grep -q .; then \ - $(CONTAINER_RUNTIME) tag $(IMAGE_LOCAL) $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null || true; \ - fi; \ - fi - @echo "Images retagged" # This always shows success - -# Runtime switching helpers -use-docker: - @echo "export CONTAINER_RUNTIME=docker" - @echo "Run: export CONTAINER_RUNTIME=docker" - -use-podman: - @echo "export CONTAINER_RUNTIME=podman" - @echo "Run: export CONTAINER_RUNTIME=podman" - -show-runtime: - @echo "Current runtime: $(CONTAINER_RUNTIME)" - @echo "Detected from: $$(command -v $(CONTAINER_RUNTIME) || echo 'not found')" # Added - @echo "To switch: make use-docker or make use-podman" - -{% endraw %} - -# ============================================================================= -# Targets -# ============================================================================= - -.PHONY: venv -venv: - @rm -Rf "$(VENV_DIR)" - @test -d "$(VENVS_DIR)" || mkdir -p "$(VENVS_DIR)" - @python3 -m venv "$(VENV_DIR)" - @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m pip install --upgrade pip setuptools pdm uv" - @echo -e "Virtual env created.\n Enter it with:\n . $(VENV_DIR)/bin/activate\n" - -.PHONY: install -install: venv - $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) - @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install ." - -.PHONY: install-dev -install-dev: venv - $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) - @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev]" - -.PHONY: install-editable -install-editable: venv - $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) - @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev]" - -.PHONY: install-grpc -install-grpc: venv - $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) - @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev,grpc]" - @echo "Installed with gRPC support (enables grpc and unix transports)" - -.PHONY: uninstall -uninstall: - pip uninstall $(PACKAGE_NAME) - -.PHONY: dist -dist: clean ## Build wheel + sdist into ./dist - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @/bin/bash -eu -c "\ - source $(VENV_DIR)/bin/activate && \ - python3 -m pip install --quiet --upgrade pip build && \ - python3 -m build" - @echo 'Wheel & sdist written to ./dist' - -.PHONY: wheel -wheel: ## Build wheel only - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @/bin/bash -eu -c "\ - source $(VENV_DIR)/bin/activate && \ - python3 -m pip install --quiet --upgrade pip build && \ - python3 -m build -w" - @echo 'Wheel written to ./dist' - -.PHONY: sdist -sdist: ## Build source distribution only - @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv - @/bin/bash -eu -c "\ - source $(VENV_DIR)/bin/activate && \ - python3 -m pip install --quiet --upgrade pip build && \ - python3 -m build -s" - @echo 'Source distribution written to ./dist' - -.PHONY: verify -verify: dist ## Build, run metadata & manifest checks - @/bin/bash -c "source $(VENV_DIR)/bin/activate && \ - twine check dist/* && \ - check-manifest && \ - pyroma -d ." - @echo "Package verified - ready to publish." - -.PHONY: lint-fix -lint-fix: - @# Handle file arguments - @target_file="$(word 2,$(MAKECMDGOALS))"; \ - if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ - actual_target="$$target_file"; \ - else \ - actual_target="$(TARGET)"; \ - fi; \ - for target in $$(echo $$actual_target); do \ - if [ ! -e "$$target" ]; then \ - echo "File/directory not found: $$target"; \ - exit 1; \ - fi; \ - done; \ - echo "Fixing lint issues in $$actual_target..."; \ - $(MAKE) --no-print-directory black TARGET="$$actual_target"; \ - $(MAKE) --no-print-directory ruff-fix TARGET="$$actual_target" - -.PHONY: lint-check -lint-check: - @# Handle file arguments - @target_file="$(word 2,$(MAKECMDGOALS))"; \ - if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ - actual_target="$$target_file"; \ - else \ - actual_target="$(TARGET)"; \ - fi; \ - for target in $$(echo $$actual_target); do \ - if [ ! -e "$$target" ]; then \ - echo "File/directory not found: $$target"; \ - exit 1; \ - fi; \ - done; \ - echo "Fixing lint issues in $$actual_target..."; \ - $(MAKE) --no-print-directory black-check TARGET="$$actual_target"; \ - $(MAKE) --no-print-directory ruff-check TARGET="$$actual_target" - -.PHONY: lock -lock: - $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`. Please run `make init`))) - uv lock - -.PHONY: test -test: - pytest tests - -.PHONY: serve -serve: - @echo "Implement me." - -.PHONY: build -build: - @$(MAKE) container-build - -.PHONY: build-grpc -build-grpc: - @echo "Building container with gRPC support..." - $(CONTAINER_RUNTIME) build \ - --platform=$(PLATFORM) \ - --build-arg INSTALL_GRPC=true \ - -f $(CONTAINER_FILE) \ - --tag $(IMAGE_BASE):$(IMAGE_TAG) \ - . - @echo "Built image with gRPC: $(call get_image_name)" - -.PHONY: start -start: - @$(MAKE) container-run - -.PHONY: start-grpc -start-grpc: - @echo "Starting container with gRPC transport on port 50051..." - @$(MAKE) container-run CONTAINER_PORT=50051 CONTAINER_INTERNAL_PORT=50051 - -.PHONY: start-grpc-tls -start-grpc-tls: container-check-image - @echo "Starting container with gRPC + mTLS on port 50051..." - @if [ ! -d "certs/grpc" ]; then \ - echo "certs/grpc directory not found"; \ - echo "Generate certificates first (see docs/using/plugins/lifecycle.md)"; \ - exit 1; \ - fi - -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true - -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true - $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ - --env-file=.env \ - -p 50051:50051 \ - -v $(PWD)/certs:/opt/app-root/src/certs:ro \ - --restart=always \ - --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ - -d $(call get_image_name) - @sleep 2 - @echo "Container started with gRPC + mTLS on port 50051" - @echo "Mounted certs from $(PWD)/certs" - -.PHONY: stop -stop: - @$(MAKE) container-stop - -.PHONY: clean -clean: - find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete - rm -rf *.egg-info .pytest_cache tests/.pytest_cache build dist .ruff_cache .coverage - -.PHONY: help -help: - @echo "This Makefile is offered for convenience." - @echo "" - @echo "The following are the valid targets for this Makefile:" - @echo "...install Install package from sources" - @echo "...install-dev Install package from sources with dev packages" - @echo "...install-editable Install package from sources in editable mode" - @echo "...install-grpc Install package with gRPC support (enables grpc/unix transports)" - @echo "...uninstall Uninstall package" - @echo "...dist Clean-build wheel *and* sdist into ./dist" - @echo "...wheel Build wheel only" - @echo "...sdist Build source distribution only" - @echo "...verify Build + twine + check-manifest + pyroma (no upload)" - @echo "...serve Start API server locally" - @echo "...build Build API server container image" - @echo "...build-grpc Build container image with gRPC support" - @echo "...start Start the API server container (port 8000)" - @echo "...start-grpc Start the API server container with gRPC (port 50051)" - @echo "...start-grpc-tls Start with gRPC + mTLS (mounts certs/)" - @echo "...stop Stop the API server container" - @echo "...lock Lock dependencies" - @echo "...lint-fix Check and fix lint errors" - @echo "...lint-check Check for lint errors" - @echo "...test Run all tests" - @echo "...clean Remove all artifacts and builds" diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/README.md b/cpex/templates/external/{{cookiecutter.plugin_slug}}/README.md deleted file mode 100644 index cfb640fb..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# {{cookiecutter.plugin_name}} for ContextForge - -{{cookiecutter.description}}. - - -## Installation - -To install dependencies with dev packages (required for linting and testing): - -```bash -make install-dev -``` - -Alternatively, you can also install it in editable mode: - -```bash -make install-editable -``` - -## Setting up the development environment - -1. Copy .env.template .env -2. Enable plugins in `.env` - -## Testing - -Test modules are created under the `tests` directory. - -To run all tests, use the following command: - -```bash -make test -``` - -**Note:** To enable logging, set `log_cli = true` in `tests/pytest.ini`. - -## Code Linting - -Before checking in any code for the project, please lint the code. This can be done using: - -```bash -make lint-fix -``` - -## Runtime (server) - -This plugin server supports multiple transport protocols: - -| Transport | Performance | Use Case | -|-----------|-------------|----------| -| `http` | ~600 calls/sec | Default, broad compatibility | -| `stdio` | ~600 calls/sec | Subprocess-based plugins | -| `grpc` | ~4,700 calls/sec | High-performance remote | -| `unix` | ~9,000 calls/sec | High-performance local IPC | - -### Running with MCP (HTTP) - Default - -```bash -# Install base dependencies -pip install . - -# Run server -PLUGINS_TRANSPORT=http ./run-server.sh -``` - -### Running with gRPC (High Performance) - -```bash -# Install with gRPC support -pip install ".[grpc]" - -# Run server -PLUGINS_TRANSPORT=grpc ./run-server.sh -``` - -### Running with Unix Socket (Highest Performance) - -```bash -# Install with gRPC support (for protobuf) -pip install ".[grpc]" - -# Run server -PLUGINS_TRANSPORT=unix ./run-server.sh -``` - -### Container Build - -To build the container image: - -```bash -# Without gRPC support (smaller image) -make build - -# With gRPC support -docker build --build-arg INSTALL_GRPC=true -t myplugin . -``` - -To run the container: - -```bash -# Default (MCP/HTTP) -make start - -# With gRPC -docker run -e PLUGINS_TRANSPORT=grpc -p 50051:50051 myplugin -``` - -To stop the container: - -```bash -make stop -``` diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml b/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml deleted file mode 100644 index 87928ea8..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml +++ /dev/null @@ -1,105 +0,0 @@ -# ---------------------------------------------------------------- -# Build system (PEP 517) -# - setuptools >= 77 gives SPDX licence support (PEP 639) -# - wheel is needed by most build front-ends -# ---------------------------------------------------------------- -[build-system] -requires = ["setuptools>=77", "wheel"] -build-backend = "setuptools.build_meta" - -# ---------------------------------------------------------------- -# Core project metadata (PEP 621) -# ---------------------------------------------------------------- -[project] -name = "{{ cookiecutter.plugin_slug }}" -version = "{{cookiecutter.version}}" -description = "{{cookiecutter.description}}" -keywords = ["MCP","API","gateway","tools", - "agents","agentic ai","model context protocol","multi-agent","fastapi", - "json-rpc","sse","websocket","federation","security","authentication" -] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Framework :: FastAPI", - "Framework :: AsyncIO", - "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", - "Topic :: Software Development :: Libraries :: Application Frameworks" -] -readme = "README.md" -requires-python = ">=3.11,<3.14" -license = "Apache-2.0" -license-files = ["LICENSE"] - -maintainers = [ - {name = "{{cookiecutter.author}}", email = "{{cookiecutter.email}}"} -] - -authors = [ - {name = "{{cookiecutter.author}}", email = "{{cookiecutter.email}}"} -] - -dependencies = [ - "mcp>=1.16.0", - "mcp-contextforge-gateway", -] - -# URLs -[project.urls] -Homepage = "https://ibm.github.io/mcp-context-forge/" -Documentation = "https://ibm.github.io/mcp-context-forge/" -Repository = "https://github.com/IBM/mcp-context-forge" -"Bug Tracker" = "https://github.com/IBM/mcp-context-forge/issues" -Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md" - -[tool.uv.sources] -mcp-contextforge-gateway = { git = "https://github.com/IBM/mcp-context-forge.git", rev = "main" } - -# ---------------------------------------------------------------- -# Optional dependency groups (extras) -# ---------------------------------------------------------------- -[project.optional-dependencies] -# gRPC transport support (higher performance than MCP/HTTP) -grpc = [ - "grpcio>=1.70.0", - "grpcio-tools>=1.70.0", - "protobuf>=5.29.0", -] - -dev = [ - "black>=25.1.0", - "pytest>=8.4.1", - "pytest-asyncio>=1.1.0", - "pytest-cov>=6.2.1", - "pytest-dotenv>=0.5.2", - "pytest-env>=1.1.5", - "pytest-examples>=0.0.18", - "pytest-md-report>=0.7.0", - "pytest-rerunfailures>=15.1", - "pytest-trio>=0.8.0", - "pytest-xdist>=3.8.0", - "ruff>=0.12.9", - "unimport>=1.2.1", - "uv>=0.8.11", -] - -# -------------------------------------------------------------------- -# setuptools-specific configuration -# -------------------------------------------------------------------- -[tool.setuptools] -include-package-data = true # ensure wheels include the data files - -# Automatic discovery: keep every package that starts with "{{ cookiecutter.plugin_slug }}" -[tool.setuptools.packages.find] -include = ["{{ cookiecutter.plugin_slug }}*"] -exclude = ["tests*"] - -## Runtime data files ------------------------------------------------ -[tool.setuptools.package-data] -{{ cookiecutter.plugin_slug }} = [ - "resources/plugins/config.yaml", -] diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/resources/plugins/config.yaml b/cpex/templates/external/{{cookiecutter.plugin_slug}}/resources/plugins/config.yaml deleted file mode 100644 index 28b6ddd4..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/resources/plugins/config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -plugins: - - name: "{{ cookiecutter.plugin_name }}" - {% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} - {% if class_parts|length > 1 -%} - {% set class_name = class_parts|map('capitalize')|join -%} - {% else -%} - {% set class_name = class_parts|join -%} - {% endif -%} - kind: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" - description: "{{ cookiecutter.description }}" - version: "{{ cookiecutter.version }}" - author: "{{ cookiecutter.author }}" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin"] - mode: "enforce" # enforce | permissive | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - # Plugin config dict passed to the plugin constructor - -# Plugin directories to scan -plugin_dirs: - - "{{ cookiecutter.plugin_slug }}" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/run-server.sh b/cpex/templates/external/{{cookiecutter.plugin_slug}}/run-server.sh deleted file mode 100755 index 41b35a62..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/run-server.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -#─────────────────────────────────────────────────────────────────────────────── -# Script : run-server.sh -# Purpose: Launch ContextForge's Plugin API -# -# Description: -# This script launches a plugin API server supporting multiple transports: -# - MCP (HTTP/stdio) - Default, uses JSON-RPC over HTTP or stdio -# - gRPC - High-performance binary protocol (requires grpc extras) -# - Unix Socket - High-performance local IPC (requires grpc extras for protobuf) -# -# Environment Variables: -# PLUGINS_TRANSPORT : Transport type: 'http', 'stdio', 'grpc', 'unix' (default: http) -# PLUGINS_CONFIG_PATH : Path to the plugin config (default: ./resources/plugins/config.yaml) -# -# # gRPC-specific settings: -# PLUGINS_GRPC_SERVER_HOST : gRPC server host (default: 0.0.0.0) -# PLUGINS_GRPC_SERVER_PORT : gRPC server port (default: 50051) -# PLUGINS_GRPC_SERVER_SSL_ENABLED : Enable TLS (true/false, required to enable TLS) -# PLUGINS_GRPC_SERVER_SSL_CERTFILE : Path to server certificate (required when SSL_ENABLED=true) -# PLUGINS_GRPC_SERVER_SSL_KEYFILE : Path to server private key (required when SSL_ENABLED=true) -# PLUGINS_GRPC_SERVER_SSL_CA_CERTS : Path to CA bundle for client verification (enables mTLS) -# PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH : Client auth mode: 'none', 'optional', 'require' (default: require) -# -# # Unix socket-specific settings: -# PLUGINS_UNIX_SOCKET_PATH : Path to Unix socket file (default: /tmp/mcpgateway-plugins.sock) -# -# Usage: -# ./run-server.sh # Run with default transport (http) -# PLUGINS_TRANSPORT=grpc ./run-server.sh # Run with gRPC transport -# PLUGINS_TRANSPORT=unix ./run-server.sh # Run with Unix socket transport -#─────────────────────────────────────────────────────────────────────────────── - -# Exit immediately on error, undefined variable, or pipe failure -set -euo pipefail - -#──────────────────────────────────────────────────────────────────────────────── -# SECTION 1: Configuration -#──────────────────────────────────────────────────────────────────────────────── -PLUGINS_CONFIG_PATH=${PLUGINS_CONFIG_PATH:-./resources/plugins/config.yaml} -PLUGINS_TRANSPORT=${PLUGINS_TRANSPORT:-http} - -echo "✓ Plugin config: ${PLUGINS_CONFIG_PATH}" -echo "✓ Transport: ${PLUGINS_TRANSPORT}" - -#──────────────────────────────────────────────────────────────────────────────── -# SECTION 2: Transport Selection -#──────────────────────────────────────────────────────────────────────────────── -case "${PLUGINS_TRANSPORT}" in - http|stdio) - # MCP transport (HTTP or stdio) - if [[ -z "${API_SERVER_SCRIPT:-}" ]]; then - API_SERVER_SCRIPT="$(python -c 'import cpex.framework.external.mcp.server.runtime as server; print(server.__file__)')" - echo "✓ MCP server script: ${API_SERVER_SCRIPT}" - fi - python "${API_SERVER_SCRIPT}" - ;; - - grpc) - # gRPC transport (requires grpc extras) - # Set sensible defaults for gRPC if not already configured - export PLUGINS_GRPC_SERVER_HOST="${PLUGINS_GRPC_SERVER_HOST:-0.0.0.0}" - export PLUGINS_GRPC_SERVER_PORT="${PLUGINS_GRPC_SERVER_PORT:-50051}" - - echo "✓ Starting gRPC plugin server..." - echo " Host: ${PLUGINS_GRPC_SERVER_HOST}" - echo " Port: ${PLUGINS_GRPC_SERVER_PORT}" - if [[ -n "${PLUGINS_GRPC_SERVER_UDS:-}" ]]; then - echo " UDS: ${PLUGINS_GRPC_SERVER_UDS}" - fi - - python -c "import grpc" 2>/dev/null || { - echo "ERROR: gRPC dependencies not installed. Install with: pip install .[grpc]" - exit 1 - } - python -m cpex.framework.external.grpc.server.runtime - ;; - - unix) - # Unix socket transport (requires protobuf from grpc extras) - echo "✓ Starting Unix socket plugin server..." - python -c "import google.protobuf" 2>/dev/null || { - echo "ERROR: Protobuf dependencies not installed. Install with: pip install .[grpc]" - exit 1 - } - python -m cpex.framework.external.unix.server.runtime - ;; - - *) - echo "ERROR: Unknown transport '${PLUGINS_TRANSPORT}'" - echo "Valid options: http, stdio, grpc, unix" - exit 1 - ;; -esac diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/__init__.py b/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/pytest.ini b/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/pytest.ini deleted file mode 100644 index ff60648e..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/pytest.ini +++ /dev/null @@ -1,13 +0,0 @@ -[pytest] -log_cli = false -log_cli_level = INFO -log_cli_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s -log_cli_date_format = %Y-%m-%d %H:%M:%S -log_level = INFO -log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s -log_date_format = %Y-%m-%d %H:%M:%S -addopts = --cov --cov-report term-missing -env_files = .env -pythonpath = . src -filterwarnings = - ignore::DeprecationWarning:pydantic.* diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/test_{{cookiecutter.plugin_slug}}.py b/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/test_{{cookiecutter.plugin_slug}}.py deleted file mode 100644 index f37d0db5..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/tests/test_{{cookiecutter.plugin_slug}}.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Tests for plugin.""" - -# Third-Party -import pytest - -# First-Party -from {{cookiecutter.plugin_slug}}.plugin import {{cookiecutter.plugin_name}} -from cpex.framework import ( - PluginConfig, - GlobalContext, - PromptPrehookPayload, -) - - -@pytest.mark.asyncio -async def test_{{cookiecutter.plugin_slug}}(): - """Test plugin prompt prefetch hook.""" - config = PluginConfig( - name="test", - kind="{{cookiecutter.plugin_slug}}.{{cookiecutter.plugin_name}}", - hooks=["prompt_pre_fetch"], - config={"setting_one": "test_value"}, - ) - - plugin = {{cookiecutter.plugin_name}}(config) - - # Test your plugin logic - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is an argument"}) - context = GlobalContext(request_id="1") - result = await plugin.prompt_pre_fetch(payload, context) - assert result.continue_processing diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/__init__.py b/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/__init__.py deleted file mode 100644 index 8b7ba30f..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""ContextForge {{cookiecutter.plugin_name}} Plugin - {{cookiecutter.description}}. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: {{cookiecutter.author}} - -""" - -import importlib.metadata - -# Package version -try: - __version__ = importlib.metadata.version("{{ cookiecutter.plugin_slug }}") -except Exception: - __version__ = "{{cookiecutter.version}}" - -__author__ = "{{cookiecutter.author}}" -__copyright__ = "Copyright 2025" -__license__ = "Apache 2.0" -__description__ = "{{cookiecutter.description}}" -__url__ = "https://ibm.github.io/mcp-context-forge/" -__download_url__ = "https://github.com/IBM/mcp-context-forge" -__packages__ = ["{{ cookiecutter.plugin_slug }}"] diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml b/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml deleted file mode 100644 index e943d5cd..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml +++ /dev/null @@ -1,9 +0,0 @@ -description: "{{cookiecutter.description}}" -author: "{{cookiecutter.author}}" -version: "{{cookiecutter.version}}" -available_hooks: - - "prompt_pre_hook" - - "prompt_post_hook" - - "tool_pre_hook" - - "tool_post_hook" -default_configs: diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin.py b/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin.py deleted file mode 100644 index 5c2db6c6..00000000 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/{{cookiecutter.plugin_slug}}/plugin.py +++ /dev/null @@ -1,90 +0,0 @@ -"""{{ cookiecutter.description }}. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: {{ cookiecutter.author }} - -This module loads configurations for plugins. -""" - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} -{% if class_parts|length > 1 -%} -{% set class_name = class_parts|map('capitalize')|join -%} -{% else -%} -{% set class_name = class_parts|join -%} -{% endif -%} -class {{ class_name }}(Plugin): - """{{ cookiecutter.description }}.""" - - def __init__(self, config: PluginConfig): - """Entry init block for plugin. - - Args: - logger: logger that the skill can make use of - config: the skill configuration - """ - super().__init__(config) - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - return ToolPostInvokeResult(continue_processing=True) diff --git a/cpex/templates/isolated/cookiecutter.json b/cpex/templates/isolated/cookiecutter.json deleted file mode 100644 index 1016c1e9..00000000 --- a/cpex/templates/isolated/cookiecutter.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugin_name": "MyFilter", - "plugin_slug": "{{ cookiecutter.plugin_name|lower|replace(' ', '_')|replace('-', '_') }}", - "version": "0.1.0", - "author": "Your Name", - "email": "your@email.com", - "description": "A filter plugin" -} diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md deleted file mode 100644 index fb0a2a5c..00000000 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# {{cookiecutter.plugin_name}} for ContextForge - -{{cookiecutter.description}}. - - -## Installation - -1. Copy .env.example .env -2. Enable plugins in `.env` -3. Add the plugin configuration to `plugins/config.yaml`: diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py deleted file mode 100644 index 11905acf..00000000 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""ContextForge {{cookiecutter.plugin_name}} Plugin - {{cookiecutter.description}}. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: {{cookiecutter.author}} - -""" diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml deleted file mode 100644 index cd793837..00000000 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml +++ /dev/null @@ -1,37 +0,0 @@ -plugins: - - name: "{{ cookiecutter.plugin_name }}" - {% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} - {% if class_parts|length > 1 -%} - {% set class_name = class_parts|map('capitalize')|join -%} - {% else -%} - {% set class_name = class_parts|join -%} - {% endif -%} - kind: "isolated_venv" - description: "{{ cookiecutter.description }}" - version: "{{ cookiecutter.version }}" - author: "{{ cookiecutter.author }}" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin"] - mode: "enforce" # enforce | permissive | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - # Plugin config dict passed to the plugin constructor - # Plugin config dict passed to the plugin constructor - class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" - requirements_file: "requirements.txt" - -# Plugin directories to scan -plugin_dirs: - - "{{ cookiecutter.plugin_slug }}" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml deleted file mode 100644 index 4614398f..00000000 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: "{{ cookiecutter.plugin_name }}" -{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} -{% if class_parts|length > 1 -%} -{% set class_name = class_parts|map('capitalize')|join -%} -{% else -%} -{% set class_name = class_parts|join -%} -{% endif -%} -description: "{{cookiecutter.description}}" -kind: "isolated_venv" -author: "{{cookiecutter.author}}" -version: "{{cookiecutter.version}}" -available_hooks: - - "prompt_pre_hook" - - "prompt_post_hook" - - "tool_pre_hook" - - "tool_post_hook" -default_config: - # Plugin config dict passed to the plugin constructor - class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" - requirements_file: "requirements.txt" -monorepo: - package_source: contextforge-plugins-python/{{ cookiecutter.plugin_slug }} -# package_info: \ No newline at end of file diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py deleted file mode 100644 index 5c2db6c6..00000000 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py +++ /dev/null @@ -1,90 +0,0 @@ -"""{{ cookiecutter.description }}. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: {{ cookiecutter.author }} - -This module loads configurations for plugins. -""" - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} -{% if class_parts|length > 1 -%} -{% set class_name = class_parts|map('capitalize')|join -%} -{% else -%} -{% set class_name = class_parts|join -%} -{% endif -%} -class {{ class_name }}(Plugin): - """{{ cookiecutter.description }}.""" - - def __init__(self, config: PluginConfig): - """Entry init block for plugin. - - Args: - logger: logger that the skill can make use of - config: the skill configuration - """ - super().__init__(config) - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - return ToolPostInvokeResult(continue_processing=True) diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt deleted file mode 100644 index 3a037075..00000000 --- a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -cpex>=0.1.0 -# The requirements file is used to install the plugin for the isolated_venv scenario -# The cpex cli tool first creates a venv for the plugin, and then uses pip to install the requirements.txt file into the venv. -# The default package name is provided below, however if monorepo installation is desired use -# a format like this: -# git+https://github.com/tedhabeck/cpex-test-plugin -{{ cookiecutter.plugin_name }} \ No newline at end of file diff --git a/cpex/templates/native/cookiecutter.json b/cpex/templates/native/cookiecutter.json deleted file mode 100644 index 1016c1e9..00000000 --- a/cpex/templates/native/cookiecutter.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugin_name": "MyFilter", - "plugin_slug": "{{ cookiecutter.plugin_name|lower|replace(' ', '_')|replace('-', '_') }}", - "version": "0.1.0", - "author": "Your Name", - "email": "your@email.com", - "description": "A filter plugin" -} diff --git a/cpex/templates/native/{{cookiecutter.plugin_slug}}/README.md b/cpex/templates/native/{{cookiecutter.plugin_slug}}/README.md deleted file mode 100644 index fb0a2a5c..00000000 --- a/cpex/templates/native/{{cookiecutter.plugin_slug}}/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# {{cookiecutter.plugin_name}} for ContextForge - -{{cookiecutter.description}}. - - -## Installation - -1. Copy .env.example .env -2. Enable plugins in `.env` -3. Add the plugin configuration to `plugins/config.yaml`: diff --git a/cpex/templates/native/{{cookiecutter.plugin_slug}}/__init__.py b/cpex/templates/native/{{cookiecutter.plugin_slug}}/__init__.py deleted file mode 100644 index 11905acf..00000000 --- a/cpex/templates/native/{{cookiecutter.plugin_slug}}/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""ContextForge {{cookiecutter.plugin_name}} Plugin - {{cookiecutter.description}}. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: {{cookiecutter.author}} - -""" diff --git a/cpex/templates/native/{{cookiecutter.plugin_slug}}/config.yaml b/cpex/templates/native/{{cookiecutter.plugin_slug}}/config.yaml deleted file mode 100644 index 28b6ddd4..00000000 --- a/cpex/templates/native/{{cookiecutter.plugin_slug}}/config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -plugins: - - name: "{{ cookiecutter.plugin_name }}" - {% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} - {% if class_parts|length > 1 -%} - {% set class_name = class_parts|map('capitalize')|join -%} - {% else -%} - {% set class_name = class_parts|join -%} - {% endif -%} - kind: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" - description: "{{ cookiecutter.description }}" - version: "{{ cookiecutter.version }}" - author: "{{ cookiecutter.author }}" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin"] - mode: "enforce" # enforce | permissive | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - # Plugin config dict passed to the plugin constructor - -# Plugin directories to scan -plugin_dirs: - - "{{ cookiecutter.plugin_slug }}" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml b/cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml deleted file mode 100644 index e943d5cd..00000000 --- a/cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml +++ /dev/null @@ -1,9 +0,0 @@ -description: "{{cookiecutter.description}}" -author: "{{cookiecutter.author}}" -version: "{{cookiecutter.version}}" -available_hooks: - - "prompt_pre_hook" - - "prompt_post_hook" - - "tool_pre_hook" - - "tool_post_hook" -default_configs: diff --git a/cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin.py b/cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin.py deleted file mode 100644 index 5c2db6c6..00000000 --- a/cpex/templates/native/{{cookiecutter.plugin_slug}}/plugin.py +++ /dev/null @@ -1,90 +0,0 @@ -"""{{ cookiecutter.description }}. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: {{ cookiecutter.author }} - -This module loads configurations for plugins. -""" - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} -{% if class_parts|length > 1 -%} -{% set class_name = class_parts|map('capitalize')|join -%} -{% else -%} -{% set class_name = class_parts|join -%} -{% endif -%} -class {{ class_name }}(Plugin): - """{{ cookiecutter.description }}.""" - - def __init__(self, config: PluginConfig): - """Entry init block for plugin. - - Args: - logger: logger that the skill can make use of - config: the skill configuration - """ - super().__init__(config) - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - return ToolPostInvokeResult(continue_processing=True) diff --git a/cpex/tools/README.md b/cpex/tools/README.md deleted file mode 100644 index dd66d634..00000000 --- a/cpex/tools/README.md +++ /dev/null @@ -1,207 +0,0 @@ -## Before you begin - -Update the environment variables in .env - -All values except PLUGINS_GITHUB_TOKEN have defaults. - -```dotenv -### Plugin installation -# Comma Separated Values used by install with --type monorepo -# The default value is https://github.com/ibm/cpex-plugins -# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins" - -# registry path (default shown below) -# PLUGIN_REGISTRY_FOLDER=data - -# Github API (default shown below) -# PLUGINS_GITHUB_API=api.github.com - -# PLUGINS_GITHUB_TOKEN= -### end Plugin installation -``` - -## Plugin installation using the cli - -```bash - python cpex/tools/cli.py plugin --help - - Usage: cli.py plugin [OPTIONS] [CMD_ACTION] [SOURCE] - - List, search, install or uninstall plugins. - -default install type is monorepo - Examples: - python cpex/tools/cli.py plugin info pii - python cpex/tools/cli.py plugin search pii - python cpex/tools/cli.py plugin --type monorepo search pii - python cpex/tools/cli.py plugin --type monorepo install cpex-pii-filter - python cpex/tools/cli.py plugin --type pypi install "ExamplePlugin@>=0.1.0" - python cpex/tools/cli.py plugin --type test-pypi install "cpex-test-plugin@>=0.1.1" - python cpex/tools/cli.py plugin --type git install "cpex-test-plugin @ git+https://github.com/tedhabeck/cpex-test-plugin@main" - python cpex/tools/cli.py plugin versions cpex-test-plugin - python cpex/tools/cli.py plugin uninstall cpex-pii-filter. - -╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ cmd_action [CMD_ACTION] One of: list|info|install|search|uninstall │ -│ source [SOURCE] The pypi, git, or local folder where the plugin resides │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --type -t TEXT The types of plugins to list. One of: monorepo|pypi|test-pypi|git|local Defaults to monorepo if unspecified. │ -│ --help Show this message and exit. │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -``` - - -## Installation catalog and plugin registry - -### Catalog update sequence diagram - -Catalog update from monorepo IBM/cpex-plugins: - -```mermaid -sequenceDiagram - participant cli - participant dotenv - participant catalog - participant pygithub - cli->>catalog: update - dotenv->>catalog: PLUGINS_REPO_URLS - dotenv->>catalog: PLUGINS_GITHUB_TOKEN - catalog->>pygithub: find pyproject.toml files - catalog->>catalog: for each pyproject.toml - catalog->>catalog: extract [project].name - catalog->>pygithub: find plugin-manifest.yaml - pygithub->>catalog: plugin-manifest.yaml - catalog-->catalog: update plugin-manifest.yaml with monorepo details - catalog->>catalog: save plugin manifest to plugin-catalog - catalog->>cli: catalog update completed -``` - -### Plugin installation sequence diagrams -Installation from git monorepo: - -`python cpex/tools/cli.py plugin --type monorepo install pii` - -```mermaid -sequenceDiagram - participant User - participant cli - participant installed_plugin_registry - participant catalog - participant subprocess - participant python - participant pip - participant git - participant monorepo - User->>cli: python cpex/tools/cli.py plugin --type monorepo install pii - cli->>catalog: update - catalog->>monorepo: get available plugins - monorepo->>catalog: available plugins - catalog->>catalog: add monorepo.package_source to downloaded plugin-manifest.yaml - catalog->>cli: available plugins - cli->>User: select plugin from available plugins - User->>cli: selected plugin - cli->>catalog: install selected plugin - catalog->>subprocess: python -m pip install git+ - subprocess->>python: -m pip install git+ - python->>pip: install git+ - pip->>git: download to site-packages - git->>monorepo: download to site-packages - monorepo->>git: package installed - git->>pip: package installed - pip->>python: package installed - python->>subprocess: rc=0 - subprocess->>catalog: plugin installed - catalog->>cli: PluginManifest - cli->>installed_plugin_registry: register plugin PluginManifest - installed_plugin_registry->>cli: plugin registered - cli->>cli: update PLUGINS_CONFIG_FILE (i.e. plugins/config.yaml) - cli->>User: plugin installed OK -``` - - Installation from pypi: - -`python cpex/tools/cli.py --type pypi install >=` - -```mermaid -sequenceDiagram - participant User - participant cli - participant catalog - participant installed_plugin_registry - participant subprocess - participant python - participant pip - participant pypi (Python Package Index) - User->>cli: python cpex/tools/cli.py plugin --type pypi install - cli->>catalog: install_from_pypi( - catalog->>subprocess: python -m pip download to temp - subprocess->>python: -m pip download to temp - python->>pip: download - pip->>pypi (Python Package Index): download to temp - pypi (Python Package Index)->>python: downloaded OK - python->>subprocess: rc=0 - subprocess->>catalog: extracted_folder - catalog->>catalog: Loads and parse the plugin-manifest.yaml - catalog->>catalog: if manifest.kind is isolated_venv initialize isolated venv and STOP here. - catalog->>cli: PluginManifest (isolated_venv) - catalog->>subprocess: python -m pip install - subprocess->>python: -m pip install - python->>pip: install - pip->>pypi (Python Package Index): download to site-packages - pypi (Python Package Index)->>python: downloaded OK - python->>subprocess: rc=0 - subprocess->>catalog: plugin installed - catalog->>catalog: load plugin manifest - catalog->>catalog: package_info.pypi_package= - catalog->>catalog: package_info.version_constraint= - catalog->>catalog: save updated manifest to plugin-catalog - catalog->>cli: PluginManifest - cli->>installed_plugin_registry: register plugin - installed_plugin_registry->>cli: plugin registered - cli->>cli: update PLUGINS_CONFIG_FILE (i.e. plugins/config.yaml) - cli->>User: plugin installed OK -``` -Note: installation from test.pypi.org is also supported using --type test-pypi. e.g: - -`python cpex/tools/cli.py plugin --type test-pypi install "cpex-plugin-test@>=0.1.1" ` - -### Uninstall - -Example uninstall of plugin: -`python cpex/tools/cli.py plugin uninstall cpex-pii-filter` - - -### Pligin information query sequence diagram - -Query information for installed plugins: - -`python cpex/tools/cli.py plugin info` - -```mermaid -sequenceDiagram - participant User - participant cli - participant installed_plugin_registry - User->>cli: python cpex/tools/cli.py plugin info - cli->>installed_plugin_registry: pii - installed_plugin_registry->>cli: InstalledPluginInfo[] - cli->>User: InstalledPluginInfo[] -``` - -Example output: -```zsh - python cpex/tools/cli.py plugin info -{ - "name": "cpex-test-plugin", - "kind": "isolated_venv", - "version": "0.2.0", - "installation_type": "monorepo", - "installation_path": "/Users/habeck/tedhabeck/contextforge-plugins-framework/plugins/cpex_test_plugin/.venv/lib/python3.13/site-packages/cpex_test_plugin", - "installed_at": "2026-05-01T00:14:26.123924+00:00Z", - "installed_by": "habeck", - "package_source": "https://github.com/tedhabeck/cpex-test-plugin", - "editable": false -} -``` \ No newline at end of file diff --git a/cpex/tools/__init__.py b/cpex/tools/__init__.py deleted file mode 100644 index 7e8358da..00000000 --- a/cpex/tools/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/tools/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Plugin tools package. -""" diff --git a/cpex/tools/catalog.py b/cpex/tools/catalog.py deleted file mode 100644 index 5d689743..00000000 --- a/cpex/tools/catalog.py +++ /dev/null @@ -1,1822 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/tools/catalog.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -This module implements the plugin catalog object. -""" - -import base64 -import datetime -import json -import logging -import os -import shutil -import subprocess -import sys -import tarfile -import tempfile -import tomllib -import uuid -import zipfile -from pathlib import Path -from typing import Any, Optional - -import httpx -import yaml -from github import Auth, Github -from packaging.version import InvalidVersion, Version - -from cpex.framework.models import ( - GitRepo, - PluginManifest, - PluginPackageInfo, - PluginVersionInfo, - PluginVersionRegistry, - PyPiRepo, -) -from cpex.framework.utils import find_package_path -from cpex.tools.integrity import ( - IntegrityVerificationError, - fetch_pypi_package_hashes, - find_matching_hash, - verify_package_integrity, -) -from cpex.tools.settings import get_catalog_settings - -logger = logging.getLogger(__name__) - - -class PluginCatalog: - """ - Utility class to initialize the plugin catalog from configured monorepos - """ - - def __init__(self) -> None: - """Utility for creating the catalog from one or more monorepos.""" - settings = get_catalog_settings() - self.github_api = settings.GITHUB_API - self.github_token = settings.GITHUB_TOKEN - self.monorepos = settings.REPO_URLS.split(",") - self.plugin_folder = settings.FOLDER - self.catalog_folder = settings.CATALOG_FOLDER - self.manifests: list[PluginManifest] = [] - # Only create Auth.Token if a token is provided to avoid errors with None - self.auth = Auth.Token(self.github_token) if self.github_token else None - self.gh = Github(auth=self.auth, base_url=f"https://{self.github_api}", per_page=100) - self.python_executable = self._get_python_executable() - - def _get_python_executable(self) -> str: - """Get the Python executable path for the current environment.""" - return sys.executable - - def create_output_folder(self) -> None: - """Create the plugin catalog output folder.""" - os.makedirs(self.catalog_folder, exist_ok=True) - - def create_folder(self, base_path, rel_path): - """ - Creates the base_path / rel_path folder to store data in. - """ - relpath = Path(base_path) / rel_path - os.makedirs(relpath, exist_ok=True) - - def create_plugin_folder(self, path: str): - """ - Creates the self.plugin_folder/path folder to store the plugin source in. - """ - self.create_folder(self.plugin_folder, path) - - def create_catalog_folder(self, path: str): - """ - Creates the OUTPUT_FOLDER/path folder to store the plugin-manifest.yaml file in. - """ - self.create_folder(self.catalog_folder, path) - - def save_manifest(self, manifest: PluginManifest, path): - """Save a pypi installed manifest to the plugin catalog. - args: - manifest: The plugin manifest to be stored in the catalog - path: the name of the plugin package that was installed - """ - relpath = Path(self.catalog_folder) / path - updated_content = yaml.safe_dump(manifest.model_dump(), default_flow_style=False) - relpath.write_text(updated_content, encoding="utf-8") - - def _ver(self, version_str: str) -> Version: - """ - Parse a version string into a Version object. - - Args: - version_str: Version string to parse (e.g., "1.0.0", "2.0.0rc1") - - Returns: - Version object. Returns Version("0") if parsing fails. - """ - try: - return Version(version_str) - except InvalidVersion: - logger.debug("Could not parse version %r as PEP 440; treating as lowest", version_str) - return Version("0") - - def update_plugin_version_registry(self, manifest: PluginManifest, relpath: Path): - """ - Update the plugin version registry with the given manifest. - args: - manifest: The plugin manifest to be stored in the catalog - relpath: the relative path of the plugin package that was installed - """ - plugin_version = PluginVersionInfo( - version=manifest.version, - manifest_file=str(relpath), - released=datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), - ) - - file_path = Path(self.catalog_folder) / manifest.name / "versions.json" - file_path.parent.mkdir(parents=True, exist_ok=True) - - # Load or create registry - if file_path.exists(): - with file_path.open("r") as f: - plugin_version_registry = PluginVersionRegistry(**json.load(f)) - else: - plugin_version_registry = PluginVersionRegistry(versions=[]) - - # Check if version already exists (avoid duplicates) - version_exists = any(pv.version == plugin_version.version for pv in plugin_version_registry.versions) - - # Add new version if not duplicate - if not version_exists: - plugin_version_registry.versions.append(plugin_version) - - # Recalculate latest version from all versions - if plugin_version_registry.versions: - plugin_version_registry.latest = max(plugin_version_registry.versions, key=lambda pv: self._ver(pv.version)) - - # Write the updated version registry to the file - file_path.write_text( - json.dumps(plugin_version_registry.model_dump(mode="json"), indent=2), - encoding="utf-8", - ) - - def save_manifest_content(self, content: str, path, repo_url: httpx.URL): - """ - write the manifest content to the supplied path relative to the ouptut folder, - injecting the monorepo.package_source value before saving the file. - """ - relpath = Path(self.catalog_folder) / path - repo_path = path.removesuffix(f"/{relpath.name}") - - manifest_data = yaml.safe_load(content) - - # Set name if not present (different from find_and_save_plugin_manifest which always sets it) - if "name" not in manifest_data: - manifest_data["name"] = repo_path - - # Use shared transformation logic - manifest_data = self._transform_manifest_data(manifest_data, manifest_data["name"], repo_path, repo_url) - - updated_content = yaml.safe_dump(manifest_data, default_flow_style=False) - relpath.write_text(updated_content, encoding="utf-8") - pm: PluginManifest = PluginManifest(**manifest_data) - self.update_plugin_version_registry(pm, relpath) - - def save_content(self, base_path, content: str, path): - """ - write the content to the supplied path relative to the ouptut folder. - """ - relpath = Path(base_path) / path - relpath.write_text(content, encoding="utf-8") - - def save_plugin_content(self, content: str, path): - """ - write the content to the supplied path relative to the plugin folder. - """ - self.save_content(self.plugin_folder, content, path) - - def save_catalog_content(self, content: str, path): - """ - write the content to the supplied path relative to the ouptut folder. - """ - self.save_content(self.catalog_folder, content, path) - - def download_contents(self, git_url: str, headers, path: str, repo_url: httpx.URL): - """ - Download the contents of the file using the github REST API. - """ - result = httpx.get(git_url, headers=headers, timeout=30.0) - if result.status_code == 200: - js = result.json() - b64_content = js["content"] - content = str(base64.b64decode(b64_content).decode("utf-8")) - # logger.info("decoded contents:\n%s", content) - # Extract directory path from full path (remove filename) - dir_path = str(Path(path).parent) if "/" in path else "" - if dir_path: - self.create_catalog_folder(dir_path) - self.save_manifest_content(content, path, repo_url) - else: - logger.error("Failed to download file: %s status_code: %d", git_url, result.status_code) - - def download_file(self, repo_path: str, item: dict, headers, gh_repo) -> str | None: - """Download the content of a github file - - Args: - repo_path: Repository path (e.g., 'owner/repo') - item: Dictionary containing the path of the file to download - headers: GitHub API headers - Returns: - Content of the file as a string or None if the file could not be downloaded - """ - # Get the repository using PyGithub - try: - file_content = gh_repo.get_contents(item["path"]) - content = file_content.decoded_content.decode("utf-8") - return content - except Exception as e: - logger.error("Failed to download file: %s error: %s", item["path"], str(e)) - - def _search_github_code_for_versions_json(self, repo_path: str, member: str | None, headers) -> list[dict] | None: - """Search GitHub for plugin-manifest*.yaml files in a specific path using PyGithub API. - - Args: - repo_path: Repository path (e.g., 'owner/repo') - member: Directory path within the repository - headers: HTTP headers for authentication (kept for compatibility but not used) - - Returns: - List of search result items as dicts with 'name' and 'git_url' keys, or None if request failed - """ - try: - # Build search query for PyGithub - search for files starting with plugin-manifest and ending with .yaml - # Note: GitHub search doesn't support wildcards in filename, so we search broadly and filter results - if member is not None: - query = f"repo:{repo_path} path:{member} filename:versions extension:json" - else: - query = f"repo:{repo_path} filename:versions extension:json" - # Use PyGithub's search_code method - search_results = self.gh.search_code(query=query) - - logger.info("Found %d versions.json files in %s/%s", search_results.totalCount, repo_path, member) - - # Convert PyGithub ContentFile objects to dict format compatible with existing code - items = [] - for content_file in search_results: - # Filter to only include files that start with "plugin-manifest" and end with ".yaml" - if content_file.name.startswith("versions") and content_file.name.endswith(".json"): - items.append( - { - "name": content_file.name, - "path": content_file.path, - "git_url": content_file.git_url, - "html_url": content_file.html_url, - } - ) - - return items - - except Exception as e: - logger.error("Catalog update failed with error: %s", str(e)) - return None - - def _search_github_code(self, repo_path: str, member: str | None, headers) -> list[dict] | None: - """Search GitHub for plugin-manifest*.yaml files in a specific path using PyGithub API. - - Args: - repo_path: Repository path (e.g., 'owner/repo') - member: Directory path within the repository - headers: HTTP headers for authentication (kept for compatibility but not used) - - Returns: - List of search result items as dicts with 'name' and 'git_url' keys, or None if request failed - """ - try: - # Build search query for PyGithub - search for files starting with plugin-manifest and ending with .yaml - # Note: GitHub search doesn't support wildcards in filename, so we search broadly and filter results - if member is not None: - query = f"repo:{repo_path} path:{member} extension:yaml" - else: - query = f"repo:{repo_path} extension:yaml" - # Use PyGithub's search_code method - search_results = self.gh.search_code(query=query) - - logger.info("Found %d plugin-manifest files in %s/%s", search_results.totalCount, repo_path, member) - - # Convert PyGithub ContentFile objects to dict format compatible with existing code - items = [] - for content_file in search_results: - # Filter to only include files that start with "plugin-manifest" and end with ".yaml" - if content_file.name.startswith("plugin-manifest") and content_file.name.endswith(".yaml"): - items.append( - { - "name": content_file.name, - "path": content_file.path, - "git_url": content_file.git_url, - "html_url": content_file.html_url, - } - ) - - return items - - except Exception as e: - logger.error("Catalog update failed with error: %s", str(e)) - return None - - def _transform_manifest_data( - self, manifest_content: dict, name: str, member: str | None, repo_url: httpx.URL - ) -> dict: - """Apply standard transformations to manifest data. - - Args: - manifest_content: Raw manifest data from YAML - name: Plugin name - member: Directory path within the repository - repo_url: Repository URL - - Returns: - Transformed manifest data with monorepo metadata - """ - if member is None: - package_source = str(repo_url) - else: - package_source = f"{repo_url}#subdirectory={member}" - - manifest_content["name"] = name - manifest_content.setdefault("tags", []) - manifest_content["monorepo"] = { - "package_source": package_source, - "repo_url": str(repo_url), - "package_folder": member if member is not None else "", - } - - # Normalize default_configs -> default_config - if "default_configs" in manifest_content: - manifest_content["default_config"] = manifest_content.pop("default_configs") or {} - - return manifest_content - - def _process_manifest_item( - self, - item: dict, - name: str, - member: str, - repo_url: httpx.URL, - headers, - relpath: Path, - repo_path: str, - gh_repo, - ) -> bool: - """Process a single manifest search result item. - - Args: - item: Search result item from GitHub API - name: Plugin name - member: Directory path within the repository - repo_url: Repository URL - headers: HTTP headers for authentication - relpath: Path where manifest should be saved - - Returns: - True if manifest was successfully processed and saved, False otherwise - """ - # Only download yaml files, not the README.md which may also contain references to available_hooks - if not (item["name"].endswith(".yaml") and item["name"].startswith("plugin-manifest")): - logger.warning("ignoring item[name]=%s. Not a yaml file.", item["name"]) - return False - - # manifest_data = self.download_file(repo_path=repo_path, git_url=item["git_url"], headers=headers) - manifest_data = self.download_file(repo_path=repo_path, item=item, headers=headers, gh_repo=gh_repo) - if manifest_data is None: - logger.error("Failed to download plugin-manifest from %s", member) - return False - - manifest_content = yaml.safe_load(manifest_data) - manifest_content = self._transform_manifest_data(manifest_content, name, member, repo_url) - - updated_content = yaml.safe_dump(manifest_content, default_flow_style=False) - relpath.write_text(updated_content, encoding="utf-8") - pm: PluginManifest = PluginManifest(**manifest_content) - self.update_plugin_version_registry(pm, relpath) - - return True - - def _process_version_item( - self, item: dict, member: str, name: str, repo_url: httpx.URL, headers, relpath, repo_path, gh_repo - ) -> None: - """Find plugin-versions.json files relative to the supplied member folder, - download and save the manifest, updating the monorepo's package_folder, package_source and repo_url attributes - Args: - member: Directory path within the repository - name: Plugin name - repo_url: Repository URL - headers: HTTP headers for authentication - """ - self.create_output_folder() - self.create_catalog_folder(name) - version_data = self.download_file(repo_path=repo_path, item=item, headers=headers, gh_repo=gh_repo) - if version_data is None: - logger.error("Skipping version item for %s (%s) — download failed", name, item.get("path")) - return - relpath.write_text(version_data, encoding="utf-8") - - def find_and_save_plugin_versions_json(self, member: str, name: str, repo_url: httpx.URL, headers, gh_repo) -> None: - """Find plugin-versions.json files relative to the supplied member folder, - download and save the manifest, updating the monorepo's package_folder, package_source and repo_url attributes - Args: - member: Directory path within the repository - name: Plugin name - repo_url: Repository URL - headers: HTTP headers for authentication - gh_repo: GitHub repository object - """ - self.create_output_folder() - self.create_catalog_folder(name) - - repo_path = repo_url.path.removeprefix("/") - items: list[dict[Any, Any]] | None = self._search_github_code_for_versions_json( - repo_path=repo_path, member=member, headers=headers - ) - if items is None: - return None - for item in items: - relpath = Path(self.catalog_folder) / name / item["name"] - self._process_version_item(item, member, name, repo_url, headers, relpath, repo_path, gh_repo) - - def find_and_save_plugin_manifest( - self, member: str, name: str, repo_url: httpx.URL, headers, gh_repo - ) -> PluginManifest | None: - """Find plugin-manifest*.yaml files relative to the supplied member folder, - download and save the manifest, updating the monorepo's package_folder, package_source and repo_url attributes - - Args: - member: Directory path within the repository - name: Plugin name - repo_url: Repository URL - headers: HTTP headers for authentication - - Returns: - None (could be extended to return PluginManifest if needed) - """ - self.create_output_folder() - self.create_catalog_folder(name) - - repo_path = repo_url.path.removeprefix("/") - - items = self._search_github_code(repo_path, member, headers) - if items is None: - return None - - for item in items: - # Use the actual filename from the search result - relpath = Path(self.catalog_folder) / name / item["name"] - self._process_manifest_item(item, name, member, repo_url, headers, relpath, repo_path, gh_repo) - - return None - - def _process_pyproject(self, gh_repo, item, repo_url: httpx.URL, headers) -> None: - """Process a single pyproject.toml file. - - Args: - gh_repo: PyGithub Repository object - item: Search result item containing pyproject.toml path - repo_url: Repository URL - headers: HTTP headers for authentication - - Raises: - Exception: If processing fails (caller should handle) - """ - # Get the directory path (remove filename) - if item.path.find("/") == -1: - member = None - else: - member = item.path.removesuffix("/" + item.name) - - # Download pyproject.toml content using PyGithub - file_content = gh_repo.get_contents(item.path) - pyproject_data = file_content.decoded_content.decode("utf-8") - - if pyproject_data is None: - logger.warning("Failed to download pyproject.toml from %s", item.path) - return - - # Parse the pyproject.toml - project_data = tomllib.loads(pyproject_data) - - # Find and save the versions.json file - self.find_and_save_plugin_versions_json( - member=member, name=project_data["project"]["name"], repo_url=repo_url, headers=headers, gh_repo=gh_repo - ) - - # Find and save the plugin manifest - self.find_and_save_plugin_manifest( - member=member, name=project_data["project"]["name"], repo_url=repo_url, headers=headers, gh_repo=gh_repo - ) - - def update_catalog_with_pyproject(self) -> bool: - """Update the catalog with the pyproject.toml file using PyGithub API.""" - if self.github_token is None: - logger.error("No GitHub token set") - return True - - headers = {"accept": "application/vnd.github+json", "authorization": f"Bearer {self.github_token}"} - self.create_output_folder() - - # Cache repositories to avoid repeated API calls - repo_cache: dict[str, Any] = {} - - for repo in self.monorepos: - repo_url = httpx.URL(repo.strip()) - repo_path = repo_url.path.removeprefix("/") - - try: - # Get repository using PyGithub (with caching) - if repo_path not in repo_cache: - repo_cache[repo_path] = self.gh.get_repo(repo_path) - gh_repo = repo_cache[repo_path] - - # Search for pyproject.toml files using PyGithub search - query = f"repo:{repo_path} filename:pyproject extension:toml" - search_results = self.gh.search_code(query=query) - - logger.info("Found %d pyproject.toml files in %s", search_results.totalCount, repo_path) - - for item in search_results: - if "pyproject.toml" in item.name: - try: - self._process_pyproject(gh_repo, item, repo_url, headers) - except Exception as e: - logger.error("Error processing pyproject.toml at %s: %s", item.path, str(e)) - continue - - except Exception as e: - logger.error("Error accessing repository %s: %s", repo_path, str(e)) - continue - - return False - - def load(self) -> None: - """Load plugin-manifest.yaml files from self.catalog_folder into self.manifests.""" - self.manifests = [] - output_path = Path(self.catalog_folder) - - if not output_path.exists(): - logger.warning("Output folder '%s' does not exist. No manifests to load.", self.catalog_folder) - return - - # Find all plugin-manifest.yaml files recursively - manifest_files = list(output_path.rglob("plugin-manifest*.yaml")) - - if not manifest_files: - logger.warning("No plugin-manifest.yaml files found in '%s'.", self.catalog_folder) - return - - logger.info("Found %d plugin-manifest.yaml file(s) in '%s'.", len(manifest_files), self.catalog_folder) - - for manifest_file in manifest_files: - try: - with open(manifest_file, "r", encoding="utf-8") as f: - manifest_data = yaml.safe_load(f) - - # Create PluginManifest object from the loaded data - manifest = PluginManifest(**manifest_data) - self.manifests.append(manifest) - logger.info("Loaded manifest from '%s'.", manifest_file) - - except Exception as e: - logger.error("Failed to load manifest from '%s': %s", manifest_file, str(e)) - - logger.info("Successfully loaded %d manifest(s).", len(self.manifests)) - - def search(self, plugin_name: str | None) -> Optional[list[PluginManifest]]: - """Search for a plugin in the catalog""" - matching: list[PluginManifest] = [] - # lookup the plugin from the catalog's plugin-manifest.yaml - if (self.manifests is not None) and (len(self.manifests) == 0): - self.load() - for manifest in self.manifests: - if plugin_name is not None: - if manifest.name.lower().count(plugin_name.lower()) > 0: - matching.append(manifest) - elif plugin_name.lower() in manifest.tags: - matching.append(manifest) - else: - matching.append(manifest) - return matching if len(matching) > 0 else None - - def find(self, plugin_name: str) -> Optional[PluginManifest]: - """Find a plugin in the catalog - Args: - plugin_name: The name of the plugin to find - Returns: - The manifest of the plugin if found, None otherwise - """ - # lookup the plugin from the catalog's plugin-manifest.yaml - if (self.manifests is not None) and (len(self.manifests) == 0): - self.load() - for manifest in self.manifests: - if manifest.name.lower() == plugin_name.lower(): - return manifest - return None - - def install_folder_via_pip(self, manifest: PluginManifest, verify_integrity: bool = True) -> Path | None: - """ - Runs a pip install using subfolder syntax for monorepo plugins. - For isolated_venv plugins, checks manifest kind BEFORE installing to avoid dependency conflicts. - e.g. "git+https://github.com[extra]&subdirectory=folder_name" - - Args: - manifest: The PluginManifest of the plugin to be installed - verify_integrity: Whether to compute and log package hash for verification - - Raises: - RuntimeError: If package installation fails. - """ - if manifest.monorepo is None: - raise RuntimeError("PluginManifest.monorepo can not be None.") - try: - repo_url = f"git+{manifest.monorepo.package_source}" - - plugin_path = None - # Check manifest kind BEFORE installing - if manifest.kind == "isolated_venv": - logger.info("Detected isolated_venv plugin from monorepo: %s", manifest.name) - # Install the package to make it available for venv initialization - package_path = self._download_monorepo_folder_to_temp( - repo_url, manifest.name, verify_integrity=verify_integrity - ) - plugin_path = self._initialize_isolated_venv(manifest, package_path) - logger.info("Isolated venv initialized. Plugin will be auto-installed via requirements.txt") - else: - # For non-isolated plugins, install normally into CLI's venv - logger.info("Installing non-isolated plugin from monorepo: %s", manifest.name) - subprocess.run( - [self.python_executable, "-m", "pip", "install", repo_url], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - logger.info("Successfully installed package: %s", manifest.name) - return plugin_path - - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Failed to install {manifest.name}: {e.stderr}") from e - except Exception as e: - raise RuntimeError(f"Unexpected error installing {manifest.name}: {str(e)}") from e - - def _install_package(self, package_name: str, version_constraint: str | None, use_test: bool = False) -> None: - """Install package from PyPI with proper error handling. - - Args: - package_name: The PyPI package name to install. - version_constraint: Optional version constraint (e.g., ">=1.0.0,<2.0.0"). - - Raises: - RuntimeError: If package installation fails. - """ - try: - # Validate package name and constraint format - ppi = PluginPackageInfo(pypi_package=package_name, version_constraint=version_constraint) - tgt = ppi.pypi_package - if ppi.version_constraint is not None: - tgt = f"{tgt}{ppi.version_constraint}" - if use_test: - subprocess.run( - [ - self.python_executable, - "-m", - "pip", - "install", - "--index-url", - "https://test.pypi.org/simple/", - tgt, - ], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - else: - subprocess.run( - [self.python_executable, "-m", "pip", "install", tgt], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - logger.info("Successfully installed package: %s", package_name) - - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Failed to install {package_name}: {e.stderr}") from e - except Exception as e: - raise RuntimeError(f"Unexpected error installing {package_name}: {str(e)}") from e - - def _load_manifest_file(self, manifest_path: Path) -> dict[str, Any]: - """Load and parse plugin-manifest.yaml with validation. - - Args: - manifest_path: Path to the plugin-manifest.yaml file. - - Returns: - Parsed manifest data as a dictionary. - - Raises: - FileNotFoundError: If manifest file doesn't exist. - RuntimeError: If manifest file cannot be parsed. - """ - if not manifest_path.exists(): - raise FileNotFoundError(f"plugin-manifest.yaml not found at {manifest_path}") - - try: - with open(manifest_path, "r", encoding="utf-8") as f: - manifest_data = yaml.safe_load(f) - - if not isinstance(manifest_data, dict): - raise RuntimeError(f"Invalid manifest format: expected dictionary, got {type(manifest_data).__name__}") - - logger.debug("Successfully loaded manifest from %s", manifest_path) - return manifest_data - - except yaml.YAMLError as e: - raise RuntimeError(f"Failed to parse manifest YAML: {str(e)}") from e - except Exception as e: - raise RuntimeError(f"Error reading manifest file: {str(e)}") from e - - def _normalize_manifest_data( - self, manifest_data: dict[str, Any], package_name: str, version_constraint: str | None - ) -> PluginManifest: - """Transform raw manifest dict into validated PluginManifest model. - - Args: - manifest_data: Raw manifest dictionary from YAML. - package_name: The PyPI package name. - version_constraint: Optional version constraint. - - Returns: - Validated PluginManifest instance. - - Raises: - RuntimeError: If manifest validation fails. - """ - try: - # Set defaults for optional fields - manifest_data.setdefault("tags", []) - manifest_data.setdefault("name", package_name) - - # Handle legacy default_configs field - if "default_config" not in manifest_data and "default_configs" in manifest_data: - manifest_data["default_config"] = manifest_data.pop("default_configs") or {} - - # Validate and create manifest - manifest = PluginManifest(**manifest_data) - - # Ensure package_info is properly set - if manifest.package_info is None: - manifest.package_info = PyPiRepo(pypi_package=package_name, version_constraint=version_constraint) - else: - manifest.package_info.pypi_package = package_name - if version_constraint is not None: - manifest.package_info.version_constraint = version_constraint - - logger.debug("Successfully normalized manifest for %s", package_name) - return manifest - - except Exception as e: - raise RuntimeError(f"Failed to validate manifest for {package_name}: {str(e)}") from e - - def _persist_manifest(self, manifest: PluginManifest, package_name: str) -> None: - """Save manifest to catalog folder. - - Args: - manifest: The validated plugin manifest. - package_name: The package name (used for folder/file naming). - - Raises: - RuntimeError: If manifest cannot be saved. - """ - try: - self.create_catalog_folder(package_name) - self.save_manifest(manifest, f"{package_name}/plugin-manifest.yaml") - logger.info("Successfully saved %s package manifest to plugin catalog", package_name) - except Exception as e: - raise RuntimeError(f"Failed to save manifest for {package_name}: {str(e)}") from e - - @staticmethod - def _safe_zip_extract(zip_ref: zipfile.ZipFile, extract_dir: Path) -> None: - """Extract a zip archive, rejecting members whose paths escape extract_dir.""" - base = extract_dir.resolve() - for info in zip_ref.infolist(): - name = info.filename - if os.path.isabs(name): - raise RuntimeError(f"Unsafe path in archive: {name}") - target = (base / name).resolve() - if not target.is_relative_to(base): - raise RuntimeError(f"Unsafe path in archive: {name}") - zip_ref.extractall(extract_dir) - - def _extract_package_archive(self, package_file: Path, extract_dir: Path) -> None: - """Extract a package archive (zip, tar.gz, wheel, etc.) to a directory. - - Args: - package_file: Path to the archive file. - extract_dir: Directory to extract to. - - Raises: - RuntimeError: If the archive format is unsupported or contains unsafe paths. - """ - if package_file.suffix == ".whl" or package_file.name.endswith(".whl"): - with zipfile.ZipFile(package_file, "r") as zip_ref: - self._safe_zip_extract(zip_ref, extract_dir) - elif package_file.suffix == ".zip" or package_file.name.endswith(".zip"): - with zipfile.ZipFile(package_file, "r") as zip_ref: - self._safe_zip_extract(zip_ref, extract_dir) - elif package_file.suffix in [".gz", ".bz2"] or ".tar" in package_file.name: - with tarfile.open(package_file, "r:*") as tar_ref: - tar_ref.extractall(extract_dir, filter="data") - else: - raise RuntimeError(f"Unsupported package format: {package_file}") - - def _download_monorepo_folder_to_temp( - self, repo_url: str, package_name: str, verify_integrity: bool = True - ) -> Path: - """Download monorepo folder to temporary directory. - - Args: - repo_url: The URL of the monorepo. - package_name: Name used in error messages. - verify_integrity: Whether to compute and log package hash for verification. - - Returns: - Path to the extracted package directory. Caller is responsible for cleanup. - """ - tmpid = uuid.uuid4() - temp_dir = Path(tempfile.mkdtemp(prefix=f"cpex_plugin_{tmpid}_")) - try: - logger.info("Downloading monorepo folder to %s", temp_dir) - - download_args = [ - self.python_executable, - "-m", - "pip", - "download", - "--no-deps", - "--dest", - str(temp_dir), - ] - download_args.append(repo_url) - - subprocess.run(download_args, check=True, capture_output=True, text=True, timeout=600) - - downloaded_files = list(temp_dir.glob("*")) - if not downloaded_files: - raise RuntimeError(f"No files downloaded for {package_name}") - package_file = downloaded_files[0] - - # Compute and log hash for integrity verification - if verify_integrity: - try: - from cpex.tools.integrity import compute_file_hash - - package_hash = compute_file_hash(package_file) - logger.info( - "Package integrity hash for %s (%s): SHA256=%s", package_name, package_file.name, package_hash - ) - logger.info("Store this hash for future verification or to detect tampering") - except Exception as e: - logger.warning("Failed to compute package hash: %s", str(e)) - - extract_dir = temp_dir / "extracted" - extract_dir.mkdir() - - self._extract_package_archive(package_file, extract_dir) - - logger.info("Downloaded and extracted %s to %s", package_name, extract_dir) - return extract_dir - - except subprocess.CalledProcessError as e: - shutil.rmtree(temp_dir, ignore_errors=True) - raise RuntimeError(f"Failed to download {package_name}: {e.stderr}") from e - except Exception as e: - shutil.rmtree(temp_dir, ignore_errors=True) - raise RuntimeError(f"Unexpected error downloading {package_name}: {str(e)}") from e - - def _download_package_to_temp( - self, package_name: str, version_constraint: str | None, use_test: bool = False, verify_integrity: bool = True - ) -> Path: - """Download package to a temporary directory without installing it. - - Args: - package_name: The PyPI package name to download. - version_constraint: Optional version constraint. - use_test: Whether to use test.pypi.org. - verify_integrity: Whether to verify package integrity using SHA256 hashes. - - Returns: - Path to the downloaded package directory. - - Raises: - RuntimeError: If download fails. - IntegrityVerificationError: If hash verification fails. - """ - - try: - # Create temporary directory - temp_dir = Path(tempfile.mkdtemp(prefix=f"cpex_plugin_{package_name}_")) - - # Validate package name and constraint format - ppi = PluginPackageInfo(pypi_package=package_name, version_constraint=version_constraint) - tgt = ppi.pypi_package - if ppi.version_constraint is not None: - tgt = f"{tgt}{ppi.version_constraint}" - - # Fetch expected hashes from PyPI before downloading (if verification enabled) - expected_hashes = {} - if verify_integrity: - try: - logger.info("Fetching package hashes from PyPI for %s", package_name) - # Extract version from constraint if available, otherwise fetch latest - version_to_fetch = None - if version_constraint: - # Try to extract exact version from constraint (e.g., "==1.0.0" -> "1.0.0") - import re - - version_match = re.search(r"==\s*([0-9.]+)", version_constraint) - if version_match: - version_to_fetch = version_match.group(1) - - expected_hashes = fetch_pypi_package_hashes( - package_name=package_name, version=version_to_fetch, use_test=use_test - ) - if expected_hashes: - logger.info("Retrieved hashes for %d distribution files", len(expected_hashes)) - else: - logger.warning("No hashes available from PyPI for %s", package_name) - except Exception as e: - logger.warning("Failed to fetch hashes from PyPI: %s. Proceeding without verification.", str(e)) - expected_hashes = {} - - # Download package without installing - download_args = [ - self.python_executable, - "-m", - "pip", - "download", - "--no-deps", # Don't download dependencies - "--dest", - str(temp_dir), - ] - - if use_test: - download_args.extend(["--index-url", "https://test.pypi.org/simple/"]) - - download_args.append(tgt) - - subprocess.run(download_args, check=True, capture_output=True, text=True, timeout=600) - - # Find the downloaded file - downloaded_files = list(temp_dir.glob("*")) - if not downloaded_files: - raise RuntimeError(f"No files downloaded for {package_name}") - - package_file = downloaded_files[0] - - # Verify package integrity if hashes are available - if verify_integrity and expected_hashes: - expected_hash = find_matching_hash(package_file, expected_hashes, package_name) - if expected_hash: - logger.info("Verifying integrity of %s", package_file.name) - verify_package_integrity( - file_path=package_file, expected_hash=expected_hash, package_name=package_name, strict=True - ) - else: - logger.warning("No matching hash found for %s. Proceeding without verification.", package_file.name) - - extract_dir = temp_dir / "extracted" - extract_dir.mkdir() - - # Extract the package using common helper - self._extract_package_archive(package_file, extract_dir) - - logger.info("Downloaded and extracted %s to %s", package_name, extract_dir) - return extract_dir - - except IntegrityVerificationError: - # Re-raise integrity errors without wrapping - shutil.rmtree(temp_dir, ignore_errors=True) - raise - except subprocess.CalledProcessError as e: - shutil.rmtree(temp_dir, ignore_errors=True) - raise RuntimeError(f"Failed to download {package_name}: {e.stderr}") from e - except Exception as e: - shutil.rmtree(temp_dir, ignore_errors=True) - raise RuntimeError(f"Unexpected error downloading {package_name}: {str(e)}") from e - - def _find_manifest_in_extracted_package(self, extract_dir: Path, package_name: str) -> Path: - """Find plugin-manifest.yaml in extracted package. - - Args: - extract_dir: Directory where package was extracted. - package_name: Name of the package. - - Returns: - Path to plugin-manifest.yaml. - - Raises: - FileNotFoundError: If manifest not found. - """ - # Search for plugin-manifest.yaml in the extracted directory - manifest_files = list(extract_dir.rglob("plugin-manifest.yaml")) - - if not manifest_files: - raise FileNotFoundError(f"plugin-manifest.yaml not found in {package_name} package") - - # Return the first manifest found - return manifest_files[0] - - def _find_requirements_in_extracted_package( - self, extract_dir: Path, package_name: str, requirements_file: str - ) -> Path: - """Find requirements file in extracted package with path traversal protection. - - Args: - extract_dir: Directory where package was extracted. - package_name: Name of the package. - requirements_file: Name of the requirements file to find. - - Returns: - Path to requirements file. - - Raises: - FileNotFoundError: If requirements file not found. - ValueError: If requirements_file contains path traversal attempts. - """ - # Validate requirements_file to prevent path traversal attacks - # Normalize the path and check for suspicious patterns - normalized_file = os.path.normpath(requirements_file) - - # Check for path traversal attempts (../, absolute paths, etc.) - if normalized_file.startswith("..") or os.path.isabs(normalized_file): - raise ValueError( - f"Invalid requirements file path '{requirements_file}': path traversal attempts are not allowed" - ) - - # Additional check: ensure no path separators that could escape the directory - if normalized_file != requirements_file.replace("\\", "/").strip("/"): - raise ValueError( - f"Invalid requirements file path '{requirements_file}': suspicious path components detected" - ) - - # Search for requirements file in the extracted directory - manifest_files = list(extract_dir.rglob(requirements_file)) - - if not manifest_files: - raise FileNotFoundError(f"requirements file {requirements_file} not found in {package_name} package") - - # Verify the found file is actually within extract_dir (defense in depth) - found_file = manifest_files[0] - try: - found_file.resolve().relative_to(extract_dir.resolve()) - except ValueError as e: - raise ValueError( - f"Security violation: requirements file '{found_file}' is outside the package directory" - ) from e - - # Return the first manifest found - return found_file - - def _initialize_isolated_venv(self, manifest: PluginManifest, package_path: Path) -> Path: - """Initialize isolated venv for a plugin without installing it into the CLI's venv. - - This method creates and initializes the target venv for isolated_venv plugins, - allowing the plugin's requirements.txt to self-reference and auto-install the plugin. - - Args: - manifest: The plugin manifest. - package_path: Path to the installed package directory. - - Raises: - RuntimeError: If venv initialization fails. - """ - try: - # Import here to avoid circular dependency - from cpex.framework.isolated.client import IsolatedVenvPlugin - from cpex.framework.models import PluginMode - - logger.info("Initializing isolated venv for plugin: %s", manifest.name) - - # Create a temporary PluginConfig from the manifest - plugin_config = manifest.create_instance_config( - instance_name=manifest.name, - mode=PluginMode.SEQUENTIAL, # Mode doesn't matter for initialization - priority=100, - ) - - # Create an IsolatedVenvPlugin instance - isolated_plugin = IsolatedVenvPlugin( - config=plugin_config, - plugin_dirs=[str(self.plugin_folder)], - ) - # TODO: sec - prevent path traversal on user supplied requirements file path. - requirements_file = manifest.default_config.get("requirements_file", "requirements.txt") - source_path = self._find_requirements_in_extracted_package(package_path, manifest.name, requirements_file) - shutil.copy(source_path, isolated_plugin.plugin_path / requirements_file) - # Initialize the venv (this will create venv and install requirements) - import asyncio - import concurrent.futures - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop is None: - asyncio.run(isolated_plugin.initialize()) - else: - # Called from within a running event loop (e.g. Jupyter, async CLI). - # Run in a thread to avoid "asyncio.run cannot be called from a running event loop". - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: - ex.submit(asyncio.run, isolated_plugin.initialize()).result() - - logger.info("Successfully initialized isolated venv for %s", manifest.name) - - return isolated_plugin.plugin_path - - except Exception as e: - raise RuntimeError(f"Failed to initialize isolated venv for {manifest.name}: {str(e)}") from e - - def _find_and_load_versions_json( - self, manifest: PluginManifest, plugin_path: Path | None, plugin_package_name: str - ) -> Path | None: - """Find and load versions.json file from installed package. - - Args: - manifest: The plugin manifest - plugin_path: Path to the installed plugin (None for isolated_venv before installation) - plugin_package_name: The package name - - This method handles two cases: - 1. For non-isolated plugins: Uses the plugin_path directly - 2. For isolated_venv plugins: Runs a subprocess in the venv to find the package path - """ - try: - actual_plugin_path = plugin_path - - # For isolated_venv plugins, we need to find the package path within the venv - if manifest.kind == "isolated_venv" and plugin_path: - # The plugin_path for isolated_venv is the venv directory - # We need to find where the package is actually installed within it - venv_path = plugin_path - python_executable = self._get_venv_python_executable(venv_path / ".venv") - - # Script receives the package name via sys.argv to avoid f-string injection. - find_package_script = """ -import sys -import importlib.metadata -from pathlib import Path - -package_name = sys.argv[1] -try: - for dist in importlib.metadata.distributions(): - if dist.name == package_name or dist.metadata.get("Name") == package_name: - if dist.files: - for afile in dist.files: - if afile.name == "versions.json": - located_path = dist.locate_file(afile) - print(str(Path(located_path).parent)) - sys.exit(0) - print("NOT_FOUND", file=sys.stderr) - sys.exit(1) -except Exception as e: - print(f"ERROR: {e}", file=sys.stderr) - sys.exit(1) -""" - - # Execute the script in the isolated venv - result = subprocess.run( - [python_executable, "-c", find_package_script, plugin_package_name], - check=True, - capture_output=True, - text=True, - timeout=60, - ) - - if result.returncode == 0 and result.stdout.strip(): - actual_plugin_path = Path(result.stdout.strip()) - logger.debug("Found package path in isolated venv: %s", actual_plugin_path) - else: - logger.warning( - "Could not find versions.json in isolated venv for %s: %s", - plugin_package_name, - result.stderr, - ) - return - - # Now load the versions.json file if it exists - if actual_plugin_path: - versions_json_path = actual_plugin_path / "versions.json" - if versions_json_path.exists(): - logger.info("Found versions.json at %s", versions_json_path) - with open(versions_json_path, "r", encoding="utf8") as f: - versions_data = json.load(f) - # Save to catalog - catalog_versions_path = Path(self.catalog_folder) / manifest.name / "versions.json" - catalog_versions_path.parent.mkdir(parents=True, exist_ok=True) - with open(catalog_versions_path, "w", encoding="utf8") as f: - json.dump(versions_data, f, indent=2) - logger.info("Saved versions.json to catalog: %s", catalog_versions_path) - return actual_plugin_path - else: - logger.debug("No versions.json found at %s", versions_json_path) - - except Exception as e: - logger.warning("Failed to find/load versions.json for %s: %s", plugin_package_name, e) - - def _get_venv_python_executable(self, venv_path: Path) -> str: - """Get the Python executable path for a virtual environment. - - Args: - venv_path: Path to the virtual environment directory - - Returns: - Path to the Python executable as a string - """ - if sys.platform == "win32": - python_exe = venv_path / "Scripts" / "python.exe" - else: - python_exe = venv_path / "bin" / "python" - - if not python_exe.exists(): - raise FileNotFoundError(f"Python executable not found at {python_exe}") - - return str(python_exe) - - def _handle_plugin_installation( - self, manifest: PluginManifest, package_path: Path, install_command: list[str] | None = None - ) -> Path | None: - """Handle plugin installation based on its kind (isolated_venv or regular). - - Args: - manifest: The plugin manifest. - package_path: Path to the package source. - install_command: Optional custom install command for non-isolated plugins. - If None, no installation is performed for non-isolated plugins. - - Returns: - Path to the installed plugin, or None if not applicable. - - Raises: - RuntimeError: If installation fails. - """ - plugin_path = None - - if manifest.kind == "isolated_venv": - logger.info("Detected isolated_venv plugin: %s", manifest.name) - plugin_path = self._initialize_isolated_venv(manifest, package_path) - logger.info("Isolated venv initialized. Plugin auto-installed via requirements.txt") - else: - # For non-isolated plugins, install if command provided - if install_command: - logger.info("Installing non-isolated plugin: %s", manifest.name) - subprocess.run( - install_command, - check=True, - capture_output=True, - text=True, - timeout=600, - ) - logger.info("Successfully installed package: %s", manifest.name) - - return plugin_path - - def _finalize_plugin_installation( - self, manifest: PluginManifest, plugin_path: Path | None, package_name: str - ) -> Path | None: - """Perform post-installation steps: persist manifest, find versions.json, update registry. - - Args: - manifest: The plugin manifest. - plugin_path: Path to the installed plugin (plugins/{manifest.name} directory). - package_name: Name of the package. - - Returns: - The actual plugin path from versions.json (inside .venv for isolated plugins), - or plugin_path if versions.json not found. - """ - # Step 1: Persist to catalog - self._persist_manifest(manifest, package_name) - - # Step 2: Find and save versions.json if available - # This returns the actual package location (inside .venv for isolated plugins) - actual_plugin_path = self._find_and_load_versions_json(manifest, plugin_path, package_name) - - # Step 3: Update the plugin version registry - # IMPORTANT: Use plugin_path (not actual_plugin_path) for the registry - # plugin_path is the plugins/{manifest.name} directory - # actual_plugin_path is the location inside .venv (for isolated plugins) - if plugin_path is not None: - self.update_plugin_version_registry(manifest=manifest, relpath=plugin_path) - - logger.info("Successfully installed and cataloged %s", package_name) - - # Return actual_plugin_path for reference (may be inside .venv) - return actual_plugin_path if actual_plugin_path is not None else plugin_path - - def install_from_pypi( - self, - plugin_package_name: str, - version_constraint: str | None = None, - use_pytest: bool = False, - verify_integrity: bool = True, - ) -> tuple[PluginManifest, Path | None]: - """Install Python package from PyPI and load its plugin-manifest.yaml. - - This method performs the following steps: - 1. Downloads package to check manifest (without installing for isolated_venv) - 2. Loads and parses the plugin-manifest.yaml - 3. Normalizes and validates the manifest data - 4. For isolated_venv plugins: initializes the target venv (plugin auto-installs via requirements.txt) - 5. For other plugins: installs normally into CLI's venv - 6. Persists the manifest to the plugin catalog - 7. Finds and saves versions.json if available - 8. Updates the plugin version registry - - Args: - plugin_package_name: The name of the package hosted on PyPI. - version_constraint: Optional version constraint (e.g., ">=1.0.0,<2.0.0"). - use_pytest: Whether to use test.pypi.org instead of pypi.org. - verify_integrity: Whether to verify package integrity using SHA256 hashes from PyPI. - - Returns: - The loaded and validated plugin manifest. - - Raises: - RuntimeError: If any step of the installation process fails. - FileNotFoundError: If plugin-manifest.yaml is not found in the package. - IntegrityVerificationError: If package hash verification fails. - """ - - # Step 1: Download package to temporary location to read manifest (with integrity verification) - temp_extract_dir = self._download_package_to_temp( - plugin_package_name, version_constraint, use_pytest, verify_integrity=verify_integrity - ) - - try: - # Step 2: Find and load the manifest file - manifest_path = self._find_manifest_in_extracted_package(temp_extract_dir, plugin_package_name) - manifest_data = self._load_manifest_file(manifest_path) - - # Step 3: Normalize and validate the manifest - manifest = self._normalize_manifest_data(manifest_data, plugin_package_name, version_constraint) - - package_path = manifest_path.parent - - # Step 4: Handle installation based on plugin kind - plugin_path = self._handle_plugin_installation( - manifest, - package_path, - install_command=None, # Will install separately for non-isolated - ) - - # For non-isolated plugins, install via pip and find package path - if manifest.kind != "isolated_venv": - self._install_package(plugin_package_name, version_constraint, use_pytest) - plugin_path = find_package_path(plugin_package_name) - - # Step 5-7: Finalize installation (persist, versions.json, registry) - plugin_path = self._finalize_plugin_installation(manifest, plugin_path, plugin_package_name) - - return manifest, plugin_path - - finally: - # Clean up temporary directory - if temp_extract_dir.exists(): - shutil.rmtree(temp_extract_dir.parent) - - def install_from_git(self, url: str, verify_integrity: bool = True) -> tuple[PluginManifest, Path | None]: - """Install Python package from Git repository and load its plugin-manifest.yaml. - - This method performs the following steps: - 1. Parses the Git URL to extract package name and repository details - 2. Downloads package to temporary location to read manifest - 3. Loads and parses the plugin-manifest.yaml - 4. Normalizes and validates the manifest data - 5. For isolated_venv plugins: initializes the target venv and installs via pip into isolated venv - 6. For other plugins: installs via pip into current venv - 7. Persists the manifest to the plugin catalog - 8. Finds and saves versions.json if available - 9. Updates the plugin version registry - - Args: - url: Git repository URL in one of these formats: - - MyProject @ git+ssh://git@git.example.com/MyProject - - MyProject @ git+https://git.example.com/MyProject - - MyProject @ git+https://git.example.com/MyProject@master - verify_integrity: Whether to compute and log package hash for verification - - Returns: - Tuple of (PluginManifest, Path to plugin or None) - - Raises: - ValueError: If URL format is invalid. - RuntimeError: If any step of the installation process fails. - FileNotFoundError: If plugin-manifest.yaml is not found in the package. - """ - # Step 1: Parse the Git URL - # Expected format: "PackageName@git+protocol://repo_url[@branch/tag/commit]" - if " @ " not in url: - raise ValueError( - f"Invalid Git URL format: '{url}'. Expected format: 'PackageName @ git+protocol://repo_url'" - ) - - package_name, git_spec = url.split(" @ ", 1) - package_name = package_name.strip() - - # Remove 'git+' prefix and extract protocol - if not git_spec.startswith("git+"): - raise ValueError(f"Git URL must start with 'git+': '{git_spec}'") - - git_url = git_spec[4:] # Remove 'git+' prefix - - # Extract branch/tag/commit if specified (after @) - git_branch_tag_commit = None - if "@" in git_url and not git_url.startswith("git@"): - # Split on the last @ to handle git@host:repo@branch format - parts = git_url.rsplit("@", 1) - if len(parts) == 2: - git_url, git_branch_tag_commit = parts - - # Validate using PluginPackageInfo - try: - PluginPackageInfo( - git_repository=git_url, - git_branch_tag_commit=git_branch_tag_commit, - ) - except Exception as e: - raise ValueError(f"Invalid Git repository URL: {str(e)}") from e - - logger.info("Installing package '%s' from Git repository: %s", package_name, git_url) - if git_branch_tag_commit: - logger.info("Using branch/tag/commit: %s", git_branch_tag_commit) - - # Step 2: Download package to temporary location to read manifest - # We'll use pip download to get the package without installing it first - temp_dir = Path(tempfile.mkdtemp(prefix="cpex_git_")) - temp_extract_dir = temp_dir / "extracted" - temp_extract_dir.mkdir(parents=True, exist_ok=True) - - try: - # Construct the full git URL for pip - pip_git_url = f"git+{git_url}" - if git_branch_tag_commit: - pip_git_url = f"{pip_git_url}@{git_branch_tag_commit}" - - # Download the package using pip - logger.info("Downloading package from Git repository...") - subprocess.run( - [ - self.python_executable, - "-m", - "pip", - "download", - "--no-deps", - "--dest", - str(temp_dir), - pip_git_url, - ], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - - # Find the downloaded archive - archives = list(temp_dir.glob("*.tar.gz")) + list(temp_dir.glob("*.zip")) + list(temp_dir.glob("*.whl")) - if not archives: - raise RuntimeError(f"No package archive found after downloading from {git_url}") - - archive_path = archives[0] - logger.info("Downloaded archive: %s", archive_path.name) - - # Compute and log hash for integrity verification - if verify_integrity: - try: - from cpex.tools.integrity import compute_file_hash - - package_hash = compute_file_hash(archive_path) - logger.info( - "Package integrity hash for %s (%s): SHA256=%s", package_name, archive_path.name, package_hash - ) - logger.info("Store this hash for future verification or to detect tampering") - except Exception as e: - logger.warning("Failed to compute package hash: %s", str(e)) - - # Extract the archive using common helper - self._extract_package_archive(archive_path, temp_extract_dir) - - # Step 3: Find and load the manifest file - manifest_path = self._find_manifest_in_extracted_package(temp_extract_dir, package_name) - manifest_data = self._load_manifest_file(manifest_path) - - # Step 4: Normalize and validate the manifest - manifest = self._normalize_manifest_data(manifest_data, package_name, None) - - # Update the manifest with the git repo information - git_repo: GitRepo = GitRepo( - git_repository=git_url, - git_branch_tag_commit=git_branch_tag_commit, - ) - manifest.git_repo = git_repo - - package_path = manifest_path.parent - install_url = f"{package_name} @ {git_spec}" - - # Step 5: Handle installation based on plugin kind - plugin_path = self._handle_plugin_installation( - manifest, - package_path, - install_command=None, # Will install separately - ) - - # Install the package from git - if manifest.kind == "isolated_venv": - # Install into isolated venv - if plugin_path is None: - raise RuntimeError(f"Failed to initialize isolated venv for {manifest.name}") - venv_python = self._get_venv_python_executable(plugin_path / ".venv") - logger.info("Installing package into isolated venv: %s", install_url) - subprocess.run( - [venv_python, "-m", "pip", "install", install_url], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - logger.info("Successfully installed into isolated venv") - else: - # Install into current venv - subprocess.run( - [self.python_executable, "-m", "pip", "install", install_url], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - plugin_path = find_package_path(package_name) - - # Step 6-8: Finalize installation (persist, versions.json, registry) - plugin_path = self._finalize_plugin_installation(manifest, plugin_path, package_name) - - return manifest, plugin_path - - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Failed to install {package_name} from Git: {e.stderr}") from e - except Exception as e: - raise RuntimeError(f"Unexpected error installing {package_name} from Git: {str(e)}") from e - finally: - # Clean up temporary directory - if temp_dir.exists(): - shutil.rmtree(temp_dir) - - def uninstall_package(self, package_name: str, manifest: PluginManifest) -> bool: - """Uninstall a Python package using pip. - - Args: - package_name: The name of the package to uninstall. - - Returns: - True if uninstallation was successful, False otherwise. - - Raises: - RuntimeError: If the uninstallation process fails. - """ - try: - if manifest.kind == "isolated_venv": - # Import here to avoid circular dependency - from cpex.framework.isolated.client import IsolatedVenvPlugin - from cpex.framework.models import PluginMode - - # Create a temporary PluginConfig from the manifest - plugin_config = manifest.create_instance_config( - instance_name=manifest.name, - mode=PluginMode.SEQUENTIAL, - priority=100, - ) - - # Create an IsolatedVenvPlugin instance - isolated_plugin = IsolatedVenvPlugin( - config=plugin_config, - plugin_dirs=[str(self.plugin_folder)], - ) - - venv_python = self._get_venv_python_executable(isolated_plugin.plugin_path / ".venv") - subprocess.run( - [venv_python, "-m", "pip", "uninstall", "-y", package_name], - check=True, - capture_output=True, - text=True, - timeout=120, - ) - isolated_plugin.remove_venv() - logger.info("Successfully uninstalled package: %s", package_name) - return True - else: - subprocess.run( - [self.python_executable, "-m", "pip", "uninstall", "-y", package_name], - check=True, - capture_output=True, - text=True, - timeout=120, - ) - logger.info("Successfully uninstalled package: %s", package_name) - return True - - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Failed to uninstall {package_name}: {e.stderr}") from e - except Exception as e: - raise RuntimeError(f"Unexpected error uninstalling {package_name}: {str(e)}") from e - - def install_from_local(self, source: Path) -> tuple[PluginManifest, Path]: - """Install a plugin from a local source directory. - - This method performs the following steps: - 1. Locates and loads pyproject.toml from source or subdirectories - 2. Finds and loads the plugin-manifest.yaml from source or subdirectories - 3. Parses and validates the manifest - 4. For isolated_venv plugins: initializes the target venv and installs in editable mode - 5. For other plugins: installs in editable mode into current environment - 6. Persists the manifest to the plugin catalog - 7. Finds and saves versions.json if available - 8. Updates the plugin version registry - - Args: - source: Path to the local plugin source directory. - - Returns: - Tuple of (PluginManifest, installation_path) where installation_path is the - path where the plugin was installed. - - Raises: - FileNotFoundError: If pyproject.toml or plugin-manifest.yaml is not found in source or subdirectories. - RuntimeError: If installation fails. - """ - # Step 1: Find and load pyproject.toml in source or subdirectories - pyproject_path = None - pyproject_data = None - - # Check in the source directory itself - candidate = source / "pyproject.toml" - if candidate.exists(): - pyproject_path = candidate - else: - # Search in subdirectories (one level deep) - for subdir in source.iterdir(): - if subdir.is_dir(): - candidate = subdir / "pyproject.toml" - if candidate.exists(): - pyproject_path = candidate - break - - if pyproject_path is None: - raise FileNotFoundError(f"pyproject.toml not found in {source} or its immediate subdirectories") - - logger.info("Found pyproject.toml at: %s", pyproject_path) - - # Load and parse the pyproject.toml - try: - with open(pyproject_path, "rb") as f: - pyproject_data = tomllib.load(f) - logger.info( - "Successfully loaded pyproject.toml with project name: %s", - pyproject_data.get("project", {}).get("name", "unknown"), - ) - except Exception as e: - raise RuntimeError(f"Failed to parse pyproject.toml at {pyproject_path}: {str(e)}") from e - - # Step 2: Find plugin-manifest.yaml in source or subdirectories - manifest_path = None - - # Check in the source directory itself - candidate = source / "plugin-manifest.yaml" - if candidate.exists(): - manifest_path = candidate - else: - # Search in subdirectories (one level deep) - for subdir in source.iterdir(): - if subdir.is_dir(): - candidate = subdir / "plugin-manifest.yaml" - if candidate.exists(): - manifest_path = candidate - break - - if manifest_path is None: - raise FileNotFoundError(f"plugin-manifest.yaml not found in {source} or its immediate subdirectories") - - logger.info("Found plugin-manifest.yaml at: %s", manifest_path) - - # Step 2: Load and parse the manifest - manifest_data = self._load_manifest_file(manifest_path) - manifest = self._normalize_manifest_data(manifest_data, pyproject_data["project"]["name"], None) - manifest.local = str(source.resolve()) - - logger.info("Loaded manifest for plugin: %s (kind: %s)", manifest.name, manifest.kind) - - plugin_path = None - - # Step 3: Install based on plugin kind - if manifest.kind == "isolated_venv": - logger.info("Installing isolated_venv plugin from local source: %s", source) - - try: - # Import here to avoid circular dependency - from cpex.framework.isolated.client import IsolatedVenvPlugin - from cpex.framework.models import PluginMode - - # Create a temporary PluginConfig from the manifest - plugin_config = manifest.create_instance_config( - instance_name=manifest.name, - mode=PluginMode.SEQUENTIAL, - priority=100, - ) - - # Create an IsolatedVenvPlugin instance - isolated_plugin = IsolatedVenvPlugin( - config=plugin_config, - plugin_dirs=[str(self.plugin_folder)], - ) - - # Initialize the venv (creates venv directory structure) - import asyncio - import concurrent.futures - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop is None: - asyncio.run(isolated_plugin.initialize()) - else: - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: - ex.submit(asyncio.run, isolated_plugin.initialize()).result() - - # Get the venv python executable - venv_path = isolated_plugin.plugin_path / ".venv" - venv_python = self._get_venv_python_executable(venv_path) - - # Install the plugin in editable mode into the isolated venv - logger.info("Installing plugin in editable mode into isolated venv: %s", venv_path) - subprocess.run( - [venv_python, "-m", "pip", "install", "-e", str(source)], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - - plugin_path = isolated_plugin.plugin_path - logger.info("Successfully installed %s into isolated venv at %s", manifest.name, plugin_path) - - except Exception as e: - raise RuntimeError(f"Failed to install isolated_venv plugin from {source}: {str(e)}") from e - - else: - # Install into current environment for non-isolated plugins - logger.info("Installing plugin from local source into current environment: %s", source) - - try: - subprocess.run( - [self.python_executable, "-m", "pip", "install", "-e", str(source)], - check=True, - capture_output=True, - text=True, - timeout=600, - ) - - # For non-isolated plugins, the plugin_path is the same folder that hosts the plugin-manifest.yaml - plugin_path = Path(str(manifest_path).removesuffix(manifest_path.name)) - if plugin_path is None: - # Fallback to source path if package path not found - plugin_path = source - - logger.info("Successfully installed %s into current environment at %s", manifest.name, plugin_path) - - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Failed to install plugin from {source}: {e.stderr}") from e - except Exception as e: - raise RuntimeError(f"Unexpected error installing plugin from {source}: {str(e)}") from e - - # Step 4: Persist to catalog - self._persist_manifest(manifest, manifest.name) - - # Step 5: Find and save versions.json if available - actual_plugin_path = self._find_and_load_versions_json(manifest, plugin_path, manifest.name) - - # Step 6: Update the plugin version registry - self.update_plugin_version_registry(manifest=manifest, relpath=plugin_path) - - logger.info("Successfully installed and cataloged %s from local source", manifest.name) - - # Return the actual plugin path if found, otherwise the original plugin_path - final_path = actual_plugin_path if actual_plugin_path is not None else plugin_path - return manifest, final_path diff --git a/cpex/tools/cli.py b/cpex/tools/cli.py deleted file mode 100644 index 07c4c982..00000000 --- a/cpex/tools/cli.py +++ /dev/null @@ -1,923 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/tools/cli.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -mcpplugins CLI ─ command line tools for authoring and packaging plugins -This module is exposed as a **console-script** via: - - [project.scripts] - mcpplugins = "cpex.tools.cli:main" - -so that a user can simply type `mcpplugins ...` to use the CLI. - -Features -───────── -* bootstrap: Creates a new plugin project from template │ -* install: Installs plugins into a Python environment │ -* package: Builds an MCP server to serve plugins as tools - -Typical usage -───────────── -```console -$ mcpplugins --help -``` -""" - -# Standard -import json -import logging -import shutil -import subprocess # nosec B404 # Safe: Used only for git commands with hardcoded args -from pathlib import Path -from typing import List, Optional - -import inquirer -import typer -from rich.console import Console -from typing_extensions import Annotated - -# First-Party -from cpex.framework.loader.config import ConfigLoader, ConfigSaver -from cpex.framework.models import ( - Config, - PluginManifest, - PluginMode, -) -from cpex.framework.settings import settings -from cpex.tools.catalog import PluginCatalog - -# Third-Party -from cpex.tools.plugin_registry import PluginRegistry -from cpex.tools.settings import get_catalog_settings - -# Exit codes for CLI commands -EXIT_SUCCESS = 0 -EXIT_GENERAL_ERROR = 1 -EXIT_INVALID_ARGS = 2 -EXIT_NOT_FOUND = 3 -EXIT_OPERATION_FAILED = 4 - -logger = logging.getLogger(__name__) -console = Console() - - -# --------------------------------------------------------------------------- -# Configuration defaults -# --------------------------------------------------------------------------- -LOCAL_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" -DEFAULT_TEMPLATE_URL = "https://github.com/contextforge-org/cpex.git" -DEFAULT_AUTHOR_NAME = "" -DEFAULT_AUTHOR_EMAIL = "" -DEFAULT_PROJECT_DIR = Path("./.") -DEFAULT_INSTALL_MANIFEST = Path("plugins/install.yaml") -DEFAULT_IMAGE_TAG = "contextforge-plugin:latest" # TBD: add plugin name and version -DEFAULT_IMAGE_BUILDER = "docker" -DEFAULT_BUILD_CONTEXT = "." -DEFAULT_CONTAINERFILE_PATH = Path("docker/Dockerfile") -DEFAULT_VCS_REF = "main" -DEFAULT_INSTALLER = "uv pip install" - -# --------------------------------------------------------------------------- -# CLI (overridable via environment variables) -# --------------------------------------------------------------------------- - -markup_mode = settings.cli_markup_mode or typer.core.DEFAULT_MARKUP_MODE -app = typer.Typer( - help="Command line tools for authoring and packaging plugins.", - add_completion=settings.cli_completion, - rich_markup_mode=None if markup_mode == "disabled" else markup_mode, -) - -# --------------------------------------------------------------------------- -# Utility functions -# --------------------------------------------------------------------------- - - -def command_exists(command_name: str) -> bool: - """Check if a given command-line utility exists and is executable. - - Args: - command_name: The name of the command to check (e.g., "ls", "git"). - - Returns: - True if the command exists and is executable, False otherwise. - """ - return shutil.which(command_name) is not None - - -def git_user_name() -> str: - """Return the current git user name from the environment. - - Returns: - The git user name configured in the user's environment. - - Examples: - >>> user_name = git_user_name() - >>> isinstance(user_name, str) - True - """ - try: - res = subprocess.run(["git", "config", "user.name"], stdout=subprocess.PIPE, check=False) # nosec B607 B603 # Safe: hardcoded git command - return res.stdout.strip().decode() if not res.returncode else DEFAULT_AUTHOR_NAME - except Exception: - return DEFAULT_AUTHOR_NAME - - -def git_user_email() -> str: - """Return the current git user email from the environment. - - Returns: - The git user email configured in the user's environment. - - Examples: - >>> user_name = git_user_email() - >>> isinstance(user_name, str) - True - """ - try: - res = subprocess.run(["git", "config", "user.email"], stdout=subprocess.PIPE, check=False) # nosec B607 B603 # Safe: hardcoded git command - return res.stdout.strip().decode() if not res.returncode else DEFAULT_AUTHOR_EMAIL - except Exception: - return DEFAULT_AUTHOR_EMAIL - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- -@app.command(help="Creates a new plugin project from template.") -def bootstrap( - destination: Annotated[ - Path, typer.Option("--destination", "-d", help="The directory in which to bootstrap the plugin project.") - ] = DEFAULT_PROJECT_DIR, - template_url: Annotated[ - str, - typer.Option( - "--template_url", - "-u", - help="The URL to the plugins cookiecutter template. Overrides local templates when provided.", - ), - ] = None, - template_type: Annotated[ - str, typer.Option("--template_type", "-t", help="Plugin template type: native or external.") - ] = "native", - vcs_ref: Annotated[ - str, - typer.Option("--vcs_ref", "-r", help="The version control system tag/branch/commit to use for the template."), - ] = DEFAULT_VCS_REF, - no_input: Annotated[bool, typer.Option("--no_input", help="Use defaults without prompting.")] = False, - dry_run: Annotated[bool, typer.Option("--dry_run", help="Run but do not make any changes.")] = False, -) -> None: - """Boostrap a new plugin project from a template. - - Args: - destination: The directory in which to bootstrap the plugin project. - template_url: The URL to the plugins cookiecutter template. - template_type: Plugin template type (native, external or isolated). - vcs_ref: The version control system tag/branch/commit to use for the template. - no_input: Use defaults without prompting. - dry_run: Run but do not make any changes. - - Raises: - Exit: If cookiecutter is not installed. - """ - try: - # Third-Party - from cookiecutter.main import cookiecutter # pylint: disable=import-outside-toplevel - except ImportError: - logger.error("cookiecutter is not installed. Install with: pip install mcp-contextforge-gateway[templating]") - raise typer.Exit(1) - - if dry_run: - source = template_url if template_url is not None else str(LOCAL_TEMPLATES_DIR / template_type) - logger.info( - "Dry run: would create plugin project at %s from template %s (type=%s)", - destination, - source, - template_type, - ) - return - - try: - output_dir = str(destination.parent) if destination.parent != destination else "." - extra_context = { - "plugin_slug": destination.name, - "author": git_user_name(), - "email": git_user_email(), - } - - # Explicit URL overrides local templates; otherwise prefer local - local_template_dir = LOCAL_TEMPLATES_DIR / template_type - use_remote = template_url is not None - - if use_remote: - if not command_exists("git"): - logger.error("git is required to fetch remote templates but was not found.") - raise typer.Exit(1) - cookiecutter( - template=template_url, - checkout=vcs_ref, - directory=f"cpex/templates/{template_type}", - output_dir=output_dir, - no_input=no_input, - extra_context=extra_context, - ) - elif local_template_dir.is_dir(): - cookiecutter( - template=str(local_template_dir), - output_dir=output_dir, - no_input=no_input, - extra_context=extra_context, - ) - elif command_exists("git"): - cookiecutter( - template=DEFAULT_TEMPLATE_URL, - checkout=vcs_ref, - directory=f"cpex/templates/{template_type}", - output_dir=output_dir, - no_input=no_input, - extra_context=extra_context, - ) - else: - logger.error("No local templates found and git is not available to fetch remote template.") - raise typer.Exit(EXIT_OPERATION_FAILED) - except (SystemExit, typer.Exit): - raise - except Exception as e: - logger.exception("An error was caught while copying template.") - console.print(f":x: Failed to create plugin project: {str(e)}") - raise typer.Exit(EXIT_OPERATION_FAILED) - - -def list_registered_plugins(type: str, fmt: str = "text") -> None: - """List the installed plugins - Args: - type (str): The type of plugins to list. Can be "native" or "external". - fmt (str): Output format — "text" (default) or "json". - """ - pr = PluginRegistry() - - registered_plugins = pr.registry.plugins - - if fmt == "json": - console.print( - json.dumps( - { - "plugins": [ - {"name": p.name, "version": p.version, "installation_type": p.installation_type} - for p in registered_plugins - ] - } - ) - ) - return - - if registered_plugins: - for plug_in in registered_plugins: - console.print( - f"name: {plug_in.name} version: {plug_in.version} installation type: {plug_in.installation_type}\n" - ) - else: - logger.info("No plugins registered.") - - -def instance_name_is_unique(config: Config, suggested_instance_name) -> bool: - """See if the instance name already exists in the plugins/config.yaml""" - if config.plugins is not None: - for a_plugin in config.plugins: - if a_plugin.name == suggested_instance_name: - return False - return True - - -def update_plugins_config_yaml(manifest: PluginManifest): - """ - Update the plugins/config.yaml file with the new plugin manifest. - - Args: - manifest (PluginManifest): The plugin manifest to be added to the config.yaml file. - Returns: - bool: True if the update was successful, False otherwise. - """ - plugin_configs: Config = ConfigLoader.load_config(settings.config_file) - suggested_name = manifest.suggest_instance_name() - ctr = 1 - while not instance_name_is_unique(plugin_configs, suggested_instance_name=suggested_name): - suggested_name = manifest.suggest_instance_name() + "_" + str(ctr) - ctr += 1 - - accepted_name = suggested_name - # TODO: prompt to confirm mode, priority etc and accepted name? - plugin_config = manifest.create_instance_config( - instance_name=accepted_name, mode=PluginMode.SEQUENTIAL, priority=100 - ) - if plugin_configs.plugins is None: - plugin_configs.plugins = [] - if plugin_configs.plugin_dirs is None or len(plugin_configs.plugin_dirs) == 0: - catalog_settings = get_catalog_settings() - plugin_configs.plugin_dirs = [f"{catalog_settings.FOLDER}"] - plugin_configs.plugins.append(plugin_config) - # now serialize the config - ConfigSaver.save_config(plugin_configs, settings.config_file) - - -def remove_from_plugins_config_yaml(manifest: PluginManifest) -> bool: - """ - Remove a plugin from the plugins/config.yaml file. - - Args: - plugin_name: The name of the plugin to remove from the config. - - Returns: - bool: True if the plugin was found and removed, False otherwise. - """ - try: - plugin_configs: Config = ConfigLoader.load_config(settings.config_file) - - if plugin_configs.plugins is None: - return False - - initial_count = len(plugin_configs.plugins) - plugin_configs.plugins = [ - p for p in plugin_configs.plugins if not (p.kind == manifest.kind and p.name.count(manifest.name) > 0) - ] - if len(plugin_configs.plugins) < initial_count: - ConfigSaver.save_config(plugin_configs, settings.config_file) - return True - - return False - except Exception as e: - logger.error("Error removing plugin from config: %s", str(e)) - return False - - -def install_from_manifest(manifest: PluginManifest, installation_type: str, catalog: PluginCatalog): - """ - Given a plugin manifest, download the plugin and register it in the plugin registry. - - Args: - manifest (PluginManifest): The plugin manifest to be installed. - installation_type (str): The type of installation, either "monorepo" or "pypi". - catalog (PluginCatalog): The plugin catalog to be used for installation. - Returns: - None: This function does not return anything. - """ - - # download the plugin to the plugins folder - if installation_type == "monorepo": - logger.info("installation type: %s", installation_type) - plugin_path = catalog.install_folder_via_pip(manifest) - actual_plugin_path = catalog._find_and_load_versions_json(manifest, plugin_path, manifest.name) - plugin_registry: PluginRegistry = PluginRegistry() - # add the newly downloaded plugin to the registry - plugin_registry.update( - manifest=manifest, - installation_type=installation_type, - catalog=catalog, - git_user_name=git_user_name(), - plugin_path=actual_plugin_path if actual_plugin_path is not None else plugin_path, - ) - update_plugins_config_yaml(manifest) - - -def select_plugin_from_catalog( - available_plugins: List[PluginManifest], assume_yes: bool = False -) -> Optional[PluginManifest]: - """Select a plugin from a list of available plugins using an interactive prompt. - - Args: - available_plugins: List of available plugin manifests to choose from. - assume_yes: When True, skip the interactive prompt and return the first - match (sorted by name/version descending). - - Returns: - The selected PluginManifest, or None if no selection was made. - """ - if not available_plugins: - return None - - # Sort plugins by name and version - available_plugins = sorted(available_plugins, key=lambda p: (p.name, p.version), reverse=True) - - if assume_yes: - selected_plugin = available_plugins[0] - installation_type = ( - "monorepo" - if selected_plugin.monorepo is not None - else "pypi" - if selected_plugin.package_info is not None - else "local" - ) - console.print( - "name: ", - selected_plugin.name, - "Version: ", - selected_plugin.version, - "type: ", - installation_type, - ) - return selected_plugin - - # Build choices list with plugin information - choices = [] - for index, plug_in in enumerate(available_plugins): - installation_type = ( - "monorepo" if plug_in.monorepo is not None else "pypi" if plug_in.package_info is not None else "local" - ) - choice = f"{index} name: {plug_in.name} version: {plug_in.version} installation type: {installation_type}" - choices.append((choice, index)) - - # Prompt user to select a plugin - questions = [ - inquirer.List( - "plugins", - message="Which plugin would you like to install?", - choices=choices, - ), - ] - answers = inquirer.prompt(questions) - - if not answers: - return None - - logger.info(json.dumps(answers)) - selected_index = int(answers["plugins"]) - selected_plugin = available_plugins[selected_index] - - # Display selected plugin information - installation_type = ( - "monorepo" - if selected_plugin.monorepo is not None - else "pypi" - if selected_plugin.package_info is not None - else "local" - ) - console.print( - "name: ", - selected_plugin.name, - "Version: ", - selected_plugin.version, - "type: ", - installation_type, - ) - - return selected_plugin - - -def _parse_pypi_source(source: str) -> tuple[str, Optional[str]]: - """Parse PyPI source string to extract package name and version constraint. - - Args: - source: PyPI package source string, optionally with version (e.g., "package@>=1.0.0"). - - Returns: - Tuple of (package_name, version_constraint). - """ - parts = source.split("@", 1) - package_name = parts[0] - version_constraint = parts[1] if len(parts) > 1 else None - return package_name, version_constraint - - -def _finalize_installation( - manifest: PluginManifest, install_type: str, catalog: PluginCatalog, plugin_path: Path | None = None -): - """Common finalization steps for plugin installation. - - Args: - manifest: The plugin manifest to finalize. - install_type: The type of installation (e.g., "pypi", "monorepo"). - catalog: The plugin catalog. - """ - plugin_registry = PluginRegistry() - editable = install_type == "local" - plugin_registry.update( - manifest=manifest, - installation_type=install_type, - catalog=catalog, - git_user_name=git_user_name(), - plugin_path=plugin_path, - editable=editable, - ) - update_plugins_config_yaml(manifest=manifest) - - -def _install_from_local(source: str, catalog: PluginCatalog, use_test: bool = False): - """Handle local-based installation (not yet implemented). - - Args: - source: local path. - catalog: The plugin catalog. - - Raises: - FileNotFoundError: If plugin-manifest.yaml is not found in source or subdirectories. - RuntimeError: If installation fails. - """ - install_source = Path(source) - with console.status(f"Installing plugin from source {source}...", spinner="dots"): - manifest, installation_path = catalog.install_from_local(install_source) - _finalize_installation(manifest, "local", catalog, installation_path) - console.print(f":white_heavy_check_mark: {manifest.name} installation complete.") - - -def _install_from_git(source: str, catalog: PluginCatalog, use_test: bool = False): - """Handle git-based installation. - - Args: - source: Git repository URL or path. - catalog: The plugin catalog. - use_test: Unused for git installations (kept for consistency). - """ - # Get integrity verification setting from catalog settings - catalog_settings = get_catalog_settings() - verify_integrity = catalog_settings.VERIFY_PACKAGE_INTEGRITY - - if verify_integrity: - console.log("Package integrity verification: enabled (hash will be computed and logged)") - else: - console.log("Package integrity verification: disabled") - - with console.status(f"Installing plugin from source {source}...", spinner="dots"): - manifest, installation_path = catalog.install_from_git(source, verify_integrity=verify_integrity) - _finalize_installation(manifest, "git", catalog, installation_path) - console.print(f":white_heavy_check_mark: {manifest.name} installation complete.") - - -def _install_from_monorepo(source: str, catalog: PluginCatalog, use_test: bool = False, assume_yes: bool = False): - """Handle monorepo-based installation. - - Args: - source: Plugin name or search term in the monorepo. - catalog: The plugin catalog. - assume_yes: Skip the interactive selection prompt. - """ - logger.info("Trying to install from git monorepo: %s", source) - available_plugins = catalog.search(source) - - if not available_plugins: - console.print("No matching plugins found.") - return - - selected_plugin = select_plugin_from_catalog(available_plugins, assume_yes=assume_yes) - if not selected_plugin: - return - - with console.status(f"Installing plugin {selected_plugin.name}...", spinner="dots"): - install_from_manifest(selected_plugin, "monorepo", catalog=catalog) - - console.print(f":white_heavy_check_mark: {selected_plugin.name} installation complete.") - - -def _install_from_pypi(source: str, catalog: PluginCatalog, use_test: bool = False): - """Handle PyPI-based installation. - - Args: - source: PyPI package name, optionally with version constraint (e.g., "package@>=1.0.0"). - catalog: The plugin catalog. - use_test: Whether to use test.pypi.org instead of pypi.org. - """ - logger.info("Trying to install from pypi package %s", source) - - # Parse version constraint - package_name, version_constraint = _parse_pypi_source(source) - - # Get integrity verification setting from catalog settings - catalog_settings = get_catalog_settings() - verify_integrity = catalog_settings.VERIFY_PACKAGE_INTEGRITY - - if verify_integrity: - console.log("Package integrity verification: enabled") - else: - console.log("Package integrity verification: disabled") - - with console.status(f"Installing plugin {package_name} via pypi", spinner="dots"): - manifest, plugin_path = catalog.install_from_pypi( - plugin_package_name=package_name, - version_constraint=version_constraint, - use_pytest=use_test, - verify_integrity=verify_integrity, - ) - - if manifest is None: - console.print(f":x: Failed to install {package_name}") - return - - _finalize_installation(manifest, "pypi", catalog, plugin_path) - console.print(f":white_heavy_check_mark: {package_name} installation complete.") - - -def install(source: str, install_type: str | None, catalog: PluginCatalog, assume_yes: bool = False): - """Install a plugin from its associated source. - - Args: - source: The source of the plugin (package name, repo URL, or search term). - install_type: The type of installation ("git", "monorepo", or "pypi"). - catalog: The catalog of plugins. - assume_yes: Skip interactive selection prompt for monorepo installs. - - Raises: - typer.Exit: With EXIT_INVALID_ARGS if install_type is not supported. - typer.Exit: With EXIT_OPERATION_FAILED if installation fails. - """ - if install_type is None: - install_type = "monorepo" - - if install_type == "monorepo": - try: - _install_from_monorepo(source, catalog, assume_yes=assume_yes) - return - except Exception as e: - console.print(f":x: Installation failed: {str(e)}") - logger.error("Install error: %s", str(e), exc_info=True) - raise typer.Exit(EXIT_OPERATION_FAILED) - - handlers = { - "git": _install_from_git, - "pypi": _install_from_pypi, - "test-pypi": _install_from_pypi, - "local": _install_from_local, - } - - handler = handlers.get(install_type) - if handler is None: - console.print( - f":x: Unsupported installation type: {install_type}. Must be one of: {', '.join(handlers.keys())}" - ) - raise typer.Exit(EXIT_INVALID_ARGS) - - try: - handler(source, catalog, use_test=True if install_type == "test-pypi" else False) - except Exception as e: - console.print(f":x: Installation failed: {str(e)}") - logger.error("Install error: %s", str(e), exc_info=True) - raise typer.Exit(EXIT_OPERATION_FAILED) - - -def versions(plugin_name: str | None, catalog: PluginCatalog, fmt: str = "text"): - """List available versions of the plugin - Args: - plugin_name (str | None): The name of the plugin to search for. - catalog (PluginCatalog): The catalog to search in. - fmt (str): Output format — "text" (default) or "json". - """ - return search(plugin_name, catalog, fmt=fmt) - - -def search(plugin_name: str | None, catalog: PluginCatalog, fmt: str = "text"): - """Search for a plugin in the catalog - Args: - plugin_name (str | None): The name of the plugin to search for. - catalog (PluginCatalog): The catalog to search in. - fmt (str): Output format — "text" (default) or "json". - Returns: - list[Plugin]: A list of plugins that match the search criteria. - """ - with console.status("Searching for available plugins ...", spinner="dots"): - available_plugins = catalog.search(plugin_name) - - if fmt == "json": - print( - json.dumps( - { - "results": [ - { - "name": p.name, - "version": p.version, - "installation_type": ( - "monorepo" - if p.monorepo is not None - else "pypi" - if p.package_info is not None - else "local" - ), - } - for p in (available_plugins or []) - ] - } - ) - ) - return - - if available_plugins: - console.log("Available plugins:") - for plug_in in available_plugins: - msg = f"name: {plug_in.name} version: {plug_in.version} installation type: {'monorepo' if plug_in.monorepo is not None else 'pypi' if plug_in.package_info is not None else 'local'}" - console.log(msg) - else: - console.log("No plugins found.") - - -def info(plugin_name: str | None, fmt: str = "text"): - """Search for or list all installed plugins - - Args: - plugin_name (str | None): The name of the plugin to search for. - If None, list all installed plugins. - fmt (str): Output format — "text" (default) or "json". - """ - registry = PluginRegistry().registry - - matches = [ - p - for p in registry.plugins - if plugin_name is None - or p.name.lower().count(plugin_name.lower()) > 0 - or p.kind.lower().count(plugin_name.lower()) > 0 - ] - - if fmt == "json": - print(json.dumps({"plugins": [p.model_dump() for p in matches]})) - return - - if matches: - for plug_in in matches: - console.print_json(json.dumps(plug_in.model_dump())) - else: - console.print("No plugins found") - - -def uninstall(plugin_name: str, catalog: PluginCatalog, assume_yes: bool = False) -> None: - """Uninstall a plugin. - - Args: - plugin_name: The name of the plugin to uninstall. - catalog: The plugin catalog. - assume_yes: Skip the confirmation prompt. - """ - # Get plugin registry to find the installed plugin - plugin_registry = PluginRegistry() - - # Find the plugin in the registry - installed_plugin = None - for plugin in plugin_registry.registry.plugins: - if plugin.name == plugin_name: - installed_plugin = plugin - break - - if installed_plugin is None: - console.print(f":x: Plugin '{plugin_name}' is not installed.") - raise typer.Exit(EXIT_NOT_FOUND) - - # Confirm uninstallation - console.print(f"Found plugin: {installed_plugin.name} (version {installed_plugin.version})") - console.print(f"Installation type: {installed_plugin.installation_type}") - console.print(f"Installation path: {installed_plugin.installation_path}") - - if not assume_yes: - questions = [ - inquirer.Confirm( - "confirm", - message=f"Are you sure you want to uninstall '{plugin_name}'?", - default=False, - ), - ] - answers = inquirer.prompt(questions) - - if not answers or not answers["confirm"]: - console.print("Uninstall cancelled.") - return - - try: - with console.status(f"Uninstalling plugin {plugin_name}...", spinner="dots"): - # retrieve the manifest so we can match on kind value - catalog = PluginCatalog() - manifest = catalog.find(plugin_name) - # Remove from plugins/config.yaml - if manifest: - remove_from_plugins_config_yaml(manifest) - catalog.uninstall_package(plugin_name, manifest) - # Remove from plugin registry - plugin_registry.remove(plugin_name) - else: - console.print(f":x: Plugin {plugin_name} not found in catalog.") - raise typer.Exit(EXIT_NOT_FOUND) - - console.print(f":white_heavy_check_mark: {plugin_name} uninstalled successfully.") - - except typer.Exit: - raise - except Exception as e: - console.print(f":x: Failed to uninstall {plugin_name}: {str(e)}") - logger.error("Uninstall error: %s", str(e), exc_info=True) - raise typer.Exit(EXIT_OPERATION_FAILED) - - -@app.command( - help="List, search, install or uninstall plugins.\n\n" - "Exit Codes:\n" - " 0 - Success\n" - " 1 - General error\n" - " 2 - Invalid arguments\n" - " 3 - Plugin not found\n" - " 4 - Operation failed\n\n" - "Default install type is monorepo\n\n" - "Examples:\n" - "python cpex/tools/cli.py plugin info pii\n" - "python cpex/tools/cli.py plugin search pii\n" - "python cpex/tools/cli.py plugin --type monorepo search pii\n" - "python cpex/tools/cli.py plugin --type monorepo install cpex-pii-filter\n" - 'python cpex/tools/cli.py plugin --type pypi install "ExamplePlugin@>=0.1.0"\n' - 'python cpex/tools/cli.py plugin --type test-pypi install "cpex-test-plugin@>=0.1.1"\n' - 'python cpex/tools/cli.py plugin --type git install "cpex-test-plugin @ git+https://github.com/tedhabeck/cpex-test-plugin@main"\n' - "python cpex/tools/cli.py plugin versions cpex-test-plugin\n" - "python cpex/tools/cli.py plugin uninstall cpex-pii-filter\n" -) -def plugin( - cmd_action: str = typer.Argument(None, help="One of: list|info|install|search|versions|uninstall"), - source: str | None = typer.Argument(None, help="The pypi, git, or local folder where the plugin resides"), - install_type: Annotated[ - str, - typer.Option( - "--type", - "-t", - help="The types of plugins to list. One of: monorepo|pypi|test-pypi|git|local Defaults to monorepo if unspecified.", - ), - ] = None, - assume_yes: Annotated[ - bool, - typer.Option( - "--yes", - "-y", - help="Bypass interactive prompts: pick the first match on install, skip confirm on uninstall.", - ), - ] = False, - fmt: Annotated[ - str, - typer.Option( - "--format", - "-f", - help="Output format for read commands: 'text' (default) or 'json'.", - ), - ] = "text", -) -> None: - """Lists installed plugins""" - if cmd_action == "info": - return info(source, fmt=fmt) - - # For uninstall, we don't need to update the catalog - if cmd_action == "uninstall": - if source is None: - console.print(":x: Please specify a plugin name to uninstall.") - raise typer.Exit(EXIT_INVALID_ARGS) - pc = PluginCatalog() - return uninstall(source, catalog=pc, assume_yes=assume_yes) - if cmd_action == "install" and source is not None: - registry = PluginRegistry() - if registry.has(source): - console.print(f"Plugin {source} is already installed.") - return - - # update the catalog before proceeding with install etc. - pc = PluginCatalog() - # optimized github search REST api takes ~14s to search & download all manifests - if install_type not in {"test-pypi", "pypi", "local"}: - console.log("Update catalog") - with console.status("Updating catalog...", spinner="dots"): - rc = pc.update_catalog_with_pyproject() - if rc: - console.log(":x: Catalog update failed.") - else: - console.log("Catalog update completed.") - - if cmd_action == "versions": - return versions(source, catalog=pc, fmt=fmt) - - if cmd_action == "list": - return list_registered_plugins(install_type, fmt=fmt) - if cmd_action == "install" and source is not None: - return install(source, install_type, catalog=pc, assume_yes=assume_yes) - if cmd_action == "search": - return search(source, catalog=pc, fmt=fmt) - - -@app.callback() -def callback() -> None: # pragma: no cover - """This function exists to force 'bootstrap' to be a subcommand.""" - - -def main() -> None: # noqa: D401 - imperative mood is fine here - """Entry point for the *mcpplugins* console script. - - Processes command line arguments, handles version requests, and forwards - all other arguments to Uvicorn with sensible defaults injected. - - Environment Variables: - PLUGINS_CLI_COMPLETION: Enable auto-completion for plugins CLI (default: false) - PLUGINS_CLI_MARKUP_MODE: Set markup mode for plugins CLI (default: rich) - Valid options: - rich: use rich markup - markdown: allow markdown in help strings - disabled: disable markup - If unset (commented out), uses "rich" if rich is detected, otherwise disables it. - """ - app() - - -if __name__ == "__main__": # pragma: no cover - executed only when run directly - # logging.basicConfig( - # level=logging.INFO, - # format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - # stream=sys.stderr, # Log to stderr to keep stdout clean for coordination - # ) - main() diff --git a/cpex/tools/integrity.py b/cpex/tools/integrity.py deleted file mode 100644 index 9f23f884..00000000 --- a/cpex/tools/integrity.py +++ /dev/null @@ -1,301 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/tools/integrity.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Package integrity verification utilities. - -This module provides SHA256 hash verification for downloaded packages -to ensure integrity beyond pip's built-in checks. It fetches expected -hashes from PyPI's JSON API and verifies downloaded files against them. - -Features -──────── -* SHA256 hash computation for package files -* PyPI JSON API integration for hash retrieval -* Configurable verification modes (strict/permissive) -* Detailed logging and error reporting - -Typical usage -───────────── -```python -from cpex.tools.integrity import verify_package_integrity, fetch_pypi_package_hashes - -# Fetch expected hashes from PyPI -hashes = fetch_pypi_package_hashes("requests", "2.31.0") - -# Verify downloaded package -verify_package_integrity(Path("/tmp/requests-2.31.0.tar.gz"), hashes["sha256"]) -``` -""" - -# Standard -import hashlib -import logging -from pathlib import Path -from typing import Optional - -import httpx - -logger = logging.getLogger(__name__) - -# Constants -PYPI_JSON_API_URL = "https://pypi.org/pypi/{package}/json" -TEST_PYPI_JSON_API_URL = "https://test.pypi.org/pypi/{package}/json" -HASH_CHUNK_SIZE = 8192 # 8KB chunks for efficient file reading - - -class IntegrityVerificationError(Exception): - """Raised when package integrity verification fails. - - This exception indicates that a downloaded package's hash does not - match the expected hash from PyPI, suggesting potential tampering - or corruption. - - Attributes: - package_name: Name of the package that failed verification. - expected_hash: The expected SHA256 hash from PyPI. - actual_hash: The computed SHA256 hash of the downloaded file. - """ - - def __init__(self, package_name: str, expected_hash: str, actual_hash: str): - """Initialize the exception with verification details. - - Args: - package_name: Name of the package that failed verification. - expected_hash: The expected SHA256 hash from PyPI. - actual_hash: The computed SHA256 hash of the downloaded file. - """ - self.package_name = package_name - self.expected_hash = expected_hash - self.actual_hash = actual_hash - super().__init__( - f"Integrity verification failed for {package_name}: " - f"expected {expected_hash[:16]}..., got {actual_hash[:16]}..." - ) - - -def compute_file_hash(file_path: Path, algorithm: str = "sha256") -> str: - """Compute cryptographic hash of a file. - - Reads the file in chunks to handle large files efficiently without - loading the entire file into memory. - - Args: - file_path: Path to the file to hash. - algorithm: Hash algorithm to use (default: sha256). - - Returns: - Hexadecimal hash string. - - Raises: - FileNotFoundError: If the file does not exist. - ValueError: If the hash algorithm is not supported. - - Examples: - >>> from pathlib import Path - >>> import tempfile - >>> with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: - ... _ = f.write("test content") - ... temp_path = Path(f.name) - >>> hash_value = compute_file_hash(temp_path) - >>> len(hash_value) - 64 - >>> temp_path.unlink() - """ - if not file_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - try: - hasher = hashlib.new(algorithm) - except ValueError as e: - raise ValueError(f"Unsupported hash algorithm: {algorithm}") from e - - with open(file_path, "rb") as f: - while chunk := f.read(HASH_CHUNK_SIZE): - hasher.update(chunk) - - hash_value = hasher.hexdigest() - logger.debug("Computed %s hash for %s: %s", algorithm, file_path.name, hash_value[:16] + "...") - return hash_value - - -def fetch_pypi_package_hashes( - package_name: str, version: Optional[str] = None, use_test: bool = False, timeout: float = 30.0 -) -> dict[str, dict[str, str]]: - """Fetch package hashes from PyPI JSON API. - - Retrieves SHA256 hashes for all distribution files of a package version - from PyPI's JSON API. If no version is specified, fetches hashes for - the latest version. - - Args: - package_name: Name of the package on PyPI. - version: Specific version to fetch hashes for (optional). - use_test: Whether to use test.pypi.org instead of pypi.org. - timeout: HTTP request timeout in seconds. - - Returns: - Dictionary mapping filename to hash information: - { - "package-1.0.0.tar.gz": { - "sha256": "abc123...", - "url": "https://files.pythonhosted.org/..." - } - } - - Raises: - RuntimeError: If the API request fails or package is not found. - - Examples: - >>> hashes = fetch_pypi_package_hashes("requests", "2.31.0") # doctest: +SKIP - >>> "requests-2.31.0.tar.gz" in hashes # doctest: +SKIP - True - """ - api_url = TEST_PYPI_JSON_API_URL if use_test else PYPI_JSON_API_URL - url = api_url.format(package=package_name) - - if version: - url = f"{url.rstrip('/json')}/{version}/json" - - logger.debug("Fetching package hashes from: %s", url) - - try: - with httpx.Client(timeout=timeout) as client: - response = client.get(url) - response.raise_for_status() - data = response.json() - - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - raise RuntimeError(f"Package '{package_name}' not found on {'test.' if use_test else ''}PyPI") from e - raise RuntimeError(f"Failed to fetch package metadata: {e}") from e - except httpx.RequestError as e: - raise RuntimeError(f"Network error fetching package metadata: {e}") from e - except Exception as e: - raise RuntimeError(f"Unexpected error fetching package metadata: {e}") from e - - # Extract hashes from the response - hashes = {} - urls = data.get("urls", []) - - if not urls: - logger.warning("No distribution files found for %s", package_name) - return hashes - - for file_info in urls: - filename = file_info.get("filename") - digests = file_info.get("digests", {}) - sha256_hash = digests.get("sha256") - file_url = file_info.get("url") - - if filename and sha256_hash: - hashes[filename] = {"sha256": sha256_hash, "url": file_url} - logger.debug("Found hash for %s: %s...", filename, sha256_hash[:16]) - - logger.info("Fetched hashes for %d distribution files of %s", len(hashes), package_name) - return hashes - - -def verify_package_integrity( - file_path: Path, expected_hash: str, package_name: Optional[str] = None, strict: bool = True -) -> bool: - """Verify package file integrity against expected SHA256 hash. - - Computes the SHA256 hash of the file and compares it to the expected - hash. In strict mode, raises an exception on mismatch. In non-strict - mode, logs a warning and returns False. - - Args: - file_path: Path to the package file to verify. - expected_hash: Expected SHA256 hash (hexadecimal string). - package_name: Name of the package (for error messages). - strict: If True, raise exception on mismatch. If False, return False. - - Returns: - True if hash matches, False if mismatch in non-strict mode. - - Raises: - IntegrityVerificationError: If hash doesn't match in strict mode. - FileNotFoundError: If the file does not exist. - - Examples: - >>> from pathlib import Path - >>> import tempfile - >>> with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: - ... _ = f.write("test") - ... temp_path = Path(f.name) - >>> expected = compute_file_hash(temp_path) - >>> verify_package_integrity(temp_path, expected, "test-pkg") - True - >>> temp_path.unlink() - """ - if not file_path.exists(): - raise FileNotFoundError(f"Package file not found: {file_path}") - - pkg_name = package_name or file_path.name - logger.info("Verifying integrity of %s", pkg_name) - - actual_hash = compute_file_hash(file_path) - - if actual_hash.lower() == expected_hash.lower(): - logger.info("✓ Integrity verification passed for %s", pkg_name) - return True - - error_msg = ( - f"Integrity verification failed for {pkg_name}\n" - f" Expected: {expected_hash}\n" - f" Actual: {actual_hash}\n" - f" File: {file_path}" - ) - - if strict: - logger.error(error_msg) - raise IntegrityVerificationError(pkg_name, expected_hash, actual_hash) - - logger.warning(error_msg) - return False - - -def find_matching_hash( - file_path: Path, hashes_dict: dict[str, dict[str, str]], package_name: Optional[str] = None -) -> Optional[str]: - """Find the expected hash for a downloaded file from PyPI hashes dictionary. - - Matches the downloaded file against the hashes dictionary by filename. - Handles various filename patterns including wheels and source distributions. - - Args: - file_path: Path to the downloaded package file. - hashes_dict: Dictionary of hashes from fetch_pypi_package_hashes(). - package_name: Name of the package (for logging). - - Returns: - The expected SHA256 hash if found, None otherwise. - - Examples: - >>> hashes = {"pkg-1.0.0.tar.gz": {"sha256": "abc123", "url": "..."}} - >>> find_matching_hash(Path("/tmp/pkg-1.0.0.tar.gz"), hashes) - 'abc123' - """ - filename = file_path.name - pkg_name = package_name or filename - - if filename in hashes_dict: - hash_value = hashes_dict[filename]["sha256"] - logger.debug("Found matching hash for %s", filename) - return hash_value - - # Try case-insensitive match - for key, value in hashes_dict.items(): - if key.lower() == filename.lower(): - hash_value = value["sha256"] - logger.debug("Found case-insensitive match for %s", filename) - return hash_value - - logger.warning("No matching hash found for %s in PyPI metadata", pkg_name) - return None - - -# Made with Bob diff --git a/cpex/tools/models.py b/cpex/tools/models.py deleted file mode 100644 index bee96ffd..00000000 --- a/cpex/tools/models.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/tools/models.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -MCP Plugins CLI models for schema validation. -This module defines models for schema validation. -""" - -# Standard - -# Third-Party -from pydantic import BaseModel - - -class InstallManifestPackage(BaseModel): - """ - A single install manifest record containing the specification of what plugin - packages and dependencies to be installed from a repository. - """ - - package: str - repository: str - extras: list[str] | None = None - - -class InstallManifest(BaseModel): - """ - An install manifest containing a list of records describing what plugin - packages and dependencies to be installed. - """ - - packages: list[InstallManifestPackage] diff --git a/cpex/tools/plugin_registry.py b/cpex/tools/plugin_registry.py deleted file mode 100644 index 29f4dda8..00000000 --- a/cpex/tools/plugin_registry.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./cpex/tools/plugin_registry.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -This module implements the plugin registry object. -""" - -import datetime -import json -import os -from pathlib import Path - -from cpex.framework.models import InstalledPluginInfo, InstalledPluginRegistry, PluginInstallationType, PluginManifest -from cpex.framework.utils import find_package_path -from cpex.tools.catalog import PluginCatalog -from cpex.tools.settings import get_plugin_registry_path - - -class PluginRegistry: - """Plugin registry. - Plugin registry is responsible for storing information about installed plugins. - """ - - registry: InstalledPluginRegistry = InstalledPluginRegistry() - - def __init__(self, *args, **kwargs): - """Initialize the plugin registry.""" - super().__init__(*args, **kwargs) - ipr_file = get_plugin_registry_path() - os.makedirs(ipr_file.parent, exist_ok=True) - if ipr_file.exists(): - try: - with open(ipr_file, "r", encoding="utf-8") as ipr: - self.registry = InstalledPluginRegistry(**json.load(ipr)) - except (json.JSONDecodeError, ValueError, KeyError) as e: - # If registry is corrupted, log error and start fresh - import logging - - logger = logging.getLogger(__name__) - logger.error( - "Corrupted plugin registry file at %s: %s. Starting with empty registry.", ipr_file, str(e) - ) - # Backup the corrupted file - backup_file = ipr_file.with_suffix(".json.corrupted") - try: - ipr_file.rename(backup_file) - logger.info("Backed up corrupted registry to %s", backup_file) - except Exception as backup_error: - logger.warning("Could not backup corrupted registry: %s", str(backup_error)) - self.registry = InstalledPluginRegistry() - else: - self.registry = InstalledPluginRegistry() - - def update( - self, - manifest: PluginManifest, - installation_type: str, - catalog: PluginCatalog, - git_user_name: str, - plugin_path: Path | None = None, - editable: bool = False, - ) -> None: - """ - Given a plugin manifest, register it in the plugin registry. - - Args: - manifest: PluginManifest: The manifest of the plugin to be registered. - installation_type: str: The type of installation (e.g., "local", "global"). - catalog: PluginCatalog: The catalog containing the plugin. - git_user_name: str: The name of the user who installed the plugin. - - Raises: - RuntimeError: If the plugin manifest is invalid or the installation type is not recognized. - """ - package_source = "" - if installation_type == "monorepo": - if manifest.monorepo is None: - raise RuntimeError("PluginManifest.monorepo can not be None.") - package_source = manifest.monorepo.package_source - elif installation_type == "pypi": - if manifest.package_info is None: - raise RuntimeError("PluginManifest.package_info can not be None.") - package_source = manifest.package_info.pypi_package - elif installation_type == "local": - if manifest.local is None: - raise RuntimeError("PluginManifest local path can not be None.") - package_source = manifest.local - elif installation_type == "git": - if manifest.git_repo is None: - raise RuntimeError("PluginManifest.git_repo can not be None.") - package_source = manifest.name + " @ " + manifest.git_repo.git_repository - if manifest.git_repo.git_branch_tag_commit is not None: - package_source += f"@{manifest.git_repo.git_branch_tag_commit}" - else: - raise ValueError(f"Invalid installation type: {installation_type}") - - installation_path = plugin_path if plugin_path is not None else find_package_path(manifest.name) - - ipi: InstalledPluginInfo = InstalledPluginInfo( - name=manifest.name, - kind=manifest.kind, - version=manifest.version, - installation_type=PluginInstallationType(installation_type), - installation_path=str(installation_path.resolve()), - installed_at=datetime.datetime.now(datetime.timezone.utc).isoformat() + "Z", - installed_by=git_user_name, - package_source=package_source, - editable=editable, - ) - # add the newly downloaded plugin to the registry - self.registry.register_plugin(ipi) - - def has(self, plugin_name: str) -> bool: - """ - Check if a plugin is installed. - Args: - plugin_name: The name of the plugin to check. - Returns: - True if the plugin is installed, False otherwise. - """ - for plugin in self.registry.plugins: - if plugin.name == plugin_name: - return True - return False - - def remove(self, plugin_name: str) -> bool: - """ - Remove a plugin from the registry. - - Args: - plugin_name: The name of the plugin to remove. - - Returns: - True if the plugin was found and removed, False otherwise. - """ - return self.registry.unregister_plugin(plugin_name) diff --git a/cpex/tools/settings.py b/cpex/tools/settings.py deleted file mode 100644 index f64111ab..00000000 --- a/cpex/tools/settings.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Location: ./cpex/tools/settings.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -This module implements the plugin catalog object. -""" - -import logging -import os -from pathlib import Path - -from dotenv import find_dotenv, load_dotenv -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - -logger = logging.getLogger(__name__) - - -load_dotenv(find_dotenv("../../.env")) - - -class CatalogSettings(BaseSettings): - """Catalog settings.""" - - model_config = SettingsConfigDict(env_prefix="PLUGINS_", env_file=".env", env_file_encoding="utf-8", extra="ignore") - - GITHUB_TOKEN: str | None = Field( - default=None, description="The github token for accessing the plugins repositories" - ) - GITHUB_API: str | None = Field(default="api.github.com", description="api.github.com") - REPO_URLS: str = Field( - default="https://github.com/ibm/cpex-plugins", description="The url of the plugins repositories comma separated" - ) - REGISTRY_FOLDER: str | None = Field( - default="data", description="The folder where the plugin registry is located (r/w)" - ) - CATALOG_FOLDER: str = Field( - default="plugin-catalog", description="The folder where the plugin catalog is located (r/w)" - ) - FOLDER: str = Field(default="plugins", description="The folder where the plugins are located (r/w)") - VERIFY_PACKAGE_INTEGRITY: bool = Field( - default=True, description="Enable SHA256 hash verification for downloaded packages from PyPI" - ) - STRICT_INTEGRITY_MODE: bool = Field( - default=False, description="Fail installation if package hashes are unavailable (strict mode)" - ) - - -def get_catalog_settings() -> CatalogSettings: - """Get catalog settings. - Returns: - CatalogSettings: Catalog settings. - """ - return CatalogSettings() - - -def get_plugin_registry_path() -> Path: - """Get the plugin registry file path. - - This centralizes the logic for determining where the plugin registry is stored. - Uses PLUGIN_REGISTRY_FILE env var if set, otherwise falls back to 'data' folder. - - Returns: - Path: Path to the installed-plugins.json file. - """ - folder = Path(os.environ.get("PLUGIN_REGISTRY_FILE", "data")) - return folder / "installed-plugins.json" diff --git a/crates/apl-cmf/Cargo.toml b/crates/apl-cmf/Cargo.toml index 141b4ea2..f0eeefc0 100644 --- a/crates/apl-cmf/Cargo.toml +++ b/crates/apl-cmf/Cargo.toml @@ -13,12 +13,20 @@ 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 = { path = "../apl-core" } -cpex-core = { path = "../cpex-core" } +apl-core = { path = "../apl-core", version = "0.2.0" } +cpex-core = { path = "../cpex-core", version = "0.2.0" } 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 index 1af89e19..7961f5b0 100644 --- a/crates/apl-cmf/src/agent.rs +++ b/crates/apl-cmf/src/agent.rs @@ -20,14 +20,28 @@ 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(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 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); @@ -61,7 +75,10 @@ mod tests { 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_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 index f69543fb..ac50f98f 100644 --- a/crates/apl-cmf/src/capability_namespaces.rs +++ b/crates/apl-cmf/src/capability_namespaces.rs @@ -92,7 +92,6 @@ const TABLE: &[CapabilityEntry] = &[ BAG_AUTHENTICATED, ], }, - // ----- Security extension (non-subject) ----- CapabilityEntry { // Labels are not extracted into discrete bag keys today — @@ -112,7 +111,6 @@ const TABLE: &[CapabilityEntry] = &[ // Exposes both inbound caller workload AND this-host workload. prefixes: &[BAG_WORKLOAD_PREFIX, BAG_CALLER_WORKLOAD_PREFIX], }, - // ----- Credential material — payload-only, no bag prefixes ----- CapabilityEntry { // Gates `Extensions.raw_credentials.inbound_tokens` — those @@ -125,13 +123,11 @@ const TABLE: &[CapabilityEntry] = &[ name: CAP_READ_DELEGATED_TOKENS, prefixes: &[], }, - // ----- Delegation chain ----- CapabilityEntry { name: CAP_READ_DELEGATION, prefixes: &[BAG_DELEGATION_PREFIX, BAG_DELEGATED], }, - // ----- Other extensions ----- CapabilityEntry { name: CAP_READ_AGENT, diff --git a/crates/apl-cmf/src/completion.rs b/crates/apl-cmf/src/completion.rs index 6a8bab82..5dc0c876 100644 --- a/crates/apl-cmf/src/completion.rs +++ b/crates/apl-cmf/src/completion.rs @@ -27,10 +27,18 @@ pub fn extract_completion(c: &CompletionExtension, bag: &mut AttributeBag) { 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); } + 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 { @@ -62,7 +70,11 @@ mod tests { #[test] fn tokens_flatten_to_nested_ints() { let c = CompletionExtension { - tokens: Some(TokenUsage { input_tokens: 100, output_tokens: 50, total_tokens: 150 }), + tokens: Some(TokenUsage { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }), latency_ms: Some(420), ..Default::default() }; diff --git a/crates/apl-cmf/src/custom.rs b/crates/apl-cmf/src/custom.rs index a933fbc3..44dfa9fc 100644 --- a/crates/apl-cmf/src/custom.rs +++ b/crates/apl-cmf/src/custom.rs @@ -32,7 +32,10 @@ mod tests { 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" })); + 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)); diff --git a/crates/apl-cmf/src/delegation.rs b/crates/apl-cmf/src/delegation.rs index 50d8f6b9..647d4a14 100644 --- a/crates/apl-cmf/src/delegation.rs +++ b/crates/apl-cmf/src/delegation.rs @@ -81,8 +81,14 @@ mod tests { 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_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 index 6c650aca..9f36dcb3 100644 --- a/crates/apl-cmf/src/extensions_bridge.rs +++ b/crates/apl-cmf/src/extensions_bridge.rs @@ -23,18 +23,42 @@ use crate::{ /// 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); } + 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)] diff --git a/crates/apl-cmf/src/framework.rs b/crates/apl-cmf/src/framework.rs index ccd39e55..aba556ed 100644 --- a/crates/apl-cmf/src/framework.rs +++ b/crates/apl-cmf/src/framework.rs @@ -16,10 +16,18 @@ 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()); } + 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); diff --git a/crates/apl-cmf/src/http.rs b/crates/apl-cmf/src/http.rs index 60d84565..a28fdf9e 100644 --- a/crates/apl-cmf/src/http.rs +++ b/crates/apl-cmf/src/http.rs @@ -18,10 +18,16 @@ use cpex_core::extensions::HttpExtension; pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { for (k, v) in &http.request_headers { - bag.set(format!("http.request_headers.{}", k.to_lowercase()), v.clone()); + 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()); + bag.set( + format!("http.response_headers.{}", k.to_lowercase()), + v.clone(), + ); } } @@ -38,8 +44,17 @@ mod tests { 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")); + 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 index dcbeda91..47c63a48 100644 --- a/crates/apl-cmf/src/lib.rs +++ b/crates/apl-cmf/src/lib.rs @@ -95,7 +95,9 @@ pub struct BagBuilder { } impl BagBuilder { - pub fn new() -> Self { Self::default() } + pub fn new() -> Self { + Self::default() + } pub fn with_security(mut self, sec: &SecurityExtension) -> Self { extract_security(sec, &mut self.bag); @@ -133,5 +135,7 @@ impl BagBuilder { self } - pub fn build(self) -> AttributeBag { self.bag } + pub fn build(self) -> AttributeBag { + self.bag + } } diff --git a/crates/apl-cmf/src/llm.rs b/crates/apl-cmf/src/llm.rs index 0dbe3332..51d964da 100644 --- a/crates/apl-cmf/src/llm.rs +++ b/crates/apl-cmf/src/llm.rs @@ -15,8 +15,12 @@ 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 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); diff --git a/crates/apl-cmf/src/mcp.rs b/crates/apl-cmf/src/mcp.rs index 9327dd1b..63f3add9 100644 --- a/crates/apl-cmf/src/mcp.rs +++ b/crates/apl-cmf/src/mcp.rs @@ -30,22 +30,42 @@ 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(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(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()); } + 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()); + } } } @@ -68,7 +88,10 @@ mod tests { 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.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")); @@ -86,7 +109,13 @@ mod tests { }; 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")); + 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 index 7f1ba17a..1444e753 100644 --- a/crates/apl-cmf/src/meta.rs +++ b/crates/apl-cmf/src/meta.rs @@ -17,13 +17,19 @@ 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 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()); } + 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()); } @@ -41,9 +47,7 @@ mod tests { 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()), - ]), + properties: HashMap::from([("owner".to_string(), "compliance".to_string())]), }; let mut bag = AttributeBag::new(); extract_meta(&meta, &mut bag); diff --git a/crates/apl-cmf/src/payload.rs b/crates/apl-cmf/src/payload.rs index 11a22f7d..9e11b0e2 100644 --- a/crates/apl-cmf/src/payload.rs +++ b/crates/apl-cmf/src/payload.rs @@ -41,10 +41,14 @@ 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) }; + 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. @@ -63,7 +67,7 @@ pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) { } // 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) => { @@ -72,8 +76,8 @@ pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) { } else if let Some(f) = n.as_f64() { bag.set(prefix, f); } - } - Value::Null => {} // Skip — equivalent to "key not present." + }, + Value::Null => {}, // Skip — equivalent to "key not present." } } diff --git a/crates/apl-cmf/src/provenance.rs b/crates/apl-cmf/src/provenance.rs index 27ddba07..b675a125 100644 --- a/crates/apl-cmf/src/provenance.rs +++ b/crates/apl-cmf/src/provenance.rs @@ -14,9 +14,15 @@ 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()); } + 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)] diff --git a/crates/apl-cmf/src/request.rs b/crates/apl-cmf/src/request.rs index 7801b71f..8f71fe18 100644 --- a/crates/apl-cmf/src/request.rs +++ b/crates/apl-cmf/src/request.rs @@ -17,11 +17,21 @@ 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()); } + 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)] diff --git a/crates/apl-cmf/src/security.rs b/crates/apl-cmf/src/security.rs index f23f9381..19d0dab7 100644 --- a/crates/apl-cmf/src/security.rs +++ b/crates/apl-cmf/src/security.rs @@ -300,8 +300,14 @@ mod tests { 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_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")); } @@ -323,7 +329,10 @@ mod tests { 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")); + assert_eq!( + bag.get_string("security.classification"), + Some("confidential") + ); } #[test] @@ -405,10 +414,7 @@ mod tests { 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.authorized_audiences", "https://api.example.com",)); assert!(bag.set_contains("client.teams", "acme")); } @@ -479,7 +485,10 @@ mod tests { 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("this_workload.attestor"), + Some("spire-agent") + ); assert_eq!(bag.get_string("caller_workload.spiffe_id"), None); } diff --git a/crates/apl-cmf/tests/end_to_end.rs b/crates/apl-cmf/tests/end_to_end.rs index cdfa1a11..57392f7b 100644 --- a/crates/apl-cmf/tests/end_to_end.rs +++ b/crates/apl-cmf/tests/end_to_end.rs @@ -56,13 +56,18 @@ routes: struct AllowPdp; #[async_trait] impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } async fn evaluate( &self, _call: &PdpCall, _bag: &AttributeBag, ) -> Result { - Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) } } @@ -163,7 +168,15 @@ async fn alice_full_route_through_cmf_bridge() { }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .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. @@ -191,7 +204,15 @@ async fn mallory_gets_both_fields_redacted_through_cmf_bridge() { }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .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. @@ -216,7 +237,15 @@ async fn deep_delegation_denies_through_cmf_bridge() { 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()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .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")); @@ -245,11 +274,19 @@ routes: 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()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => { assert!(rule_source.contains("policy"), "got source {}", rule_source); - } + }, d => panic!("expected Deny on include_ssn, got {:?}", d), } } @@ -270,6 +307,14 @@ async fn anonymous_user_denied_at_authenticated_check() { 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()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; assert!(matches!(r.decision, Decision::Deny { .. })); } diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml index b04b0951..95c7e372 100644 --- a/crates/apl-core/Cargo.toml +++ b/crates/apl-core/Cargo.toml @@ -13,6 +13,11 @@ 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] # Plain rlib; APL is consumed by other workspace crates (apl-cmf, apl-cpex) @@ -26,7 +31,10 @@ thiserror = { workspace = true } async-trait = { workspace = true } regex = { workspace = true } futures = { workspace = true } -cpex-orchestration = { path = "../cpex-orchestration" } +cpex-orchestration = { path = "../cpex-orchestration", version = "0.2.0" } [dev-dependencies] tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs index 17bac0e0..c7fcca40 100644 --- a/crates/apl-core/src/attributes.rs +++ b/crates/apl-core/src/attributes.rs @@ -40,22 +40,34 @@ pub enum AttributeValue { } impl From for AttributeValue { - fn from(v: bool) -> Self { AttributeValue::Bool(v) } + fn from(v: bool) -> Self { + AttributeValue::Bool(v) + } } impl From for AttributeValue { - fn from(v: i64) -> Self { AttributeValue::Int(v) } + fn from(v: i64) -> Self { + AttributeValue::Int(v) + } } impl From for AttributeValue { - fn from(v: f64) -> Self { AttributeValue::Float(v) } + fn from(v: f64) -> Self { + AttributeValue::Float(v) + } } impl From<&str> for AttributeValue { - fn from(v: &str) -> Self { AttributeValue::String(v.to_string()) } + fn from(v: &str) -> Self { + AttributeValue::String(v.to_string()) + } } impl From for AttributeValue { - fn from(v: String) -> Self { AttributeValue::String(v) } + fn from(v: String) -> Self { + AttributeValue::String(v) + } } impl From> for AttributeValue { - fn from(v: HashSet) -> Self { AttributeValue::StringSet(v) } + fn from(v: HashSet) -> Self { + AttributeValue::StringSet(v) + } } /// Flat key→value namespace consumed by the evaluator. @@ -71,7 +83,9 @@ pub struct AttributeBag { impl AttributeBag { pub fn new() -> Self { - Self { attrs: HashMap::new() } + Self { + attrs: HashMap::new(), + } } pub fn set(&mut self, key: impl Into, value: impl Into) { diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs index aa5f80d5..bcfd1889 100644 --- a/crates/apl-core/src/evaluator.rs +++ b/crates/apl-core/src/evaluator.rs @@ -61,14 +61,12 @@ pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision { // `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()); + 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, @@ -94,13 +92,21 @@ fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool { Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false), Condition::Exists { key } => bag.contains(key), Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag), - Condition::InSet { value_key, set_key, negate } => { + Condition::InSet { + value_key, + set_key, + negate, + } => { let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) { (Some(s), Some(set)) => set.contains(s), _ => false, // missing key or wrong type → not in set }; - if *negate { !in_set } else { in_set } - } + if *negate { + !in_set + } else { + in_set + } + }, } } @@ -119,7 +125,7 @@ fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) CompareOp::NotEq => !values_eq(attr, lit), CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => { numeric_compare(attr, lit, op) - } + }, } } @@ -220,10 +226,10 @@ pub async fn evaluate_effects( )) .await { - EffectOutcome::Continue => {} + EffectOutcome::Continue => {}, EffectOutcome::Halt(decision) => { return StepsEvaluation::deny(decision, taints, args_modified, result_modified); - } + }, } } StepsEvaluation { @@ -315,14 +321,12 @@ async fn dispatch_effect( // 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()); + 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 @@ -337,13 +341,13 @@ async fn dispatch_effect( 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 { @@ -360,10 +364,7 @@ async fn dispatch_effect( 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), - ); + bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set)); } if let Some(aud) = &outcome.granted_audience { bag.set(bk::GRANTED_AUDIENCE, aud.clone()); @@ -372,7 +373,7 @@ async fn dispatch_effect( 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 @@ -388,7 +389,7 @@ async fn dispatch_effect( } else { EffectOutcome::Halt(outcome.decision) } - } + }, }, Err(e) => { // Transport / lookup failure. on_error treats this @@ -409,9 +410,9 @@ async fn dispatch_effect( rule_source: delegate_step.source.clone(), }) } - } + }, } - } + }, Effect::Taint { label, scopes } => { // Emit the taint into the phase's accumulator so it flows @@ -424,7 +425,7 @@ async fn dispatch_effect( scopes: scopes.clone(), }); EffectOutcome::Continue - } + }, Effect::FieldOp { path, stages } => { dispatch_field_op( @@ -440,7 +441,7 @@ async fn dispatch_effect( payload, ) .await - } + }, Effect::Sequential(effects) => { // Semantically the same as inlining the list into the @@ -468,7 +469,7 @@ async fn dispatch_effect( } } EffectOutcome::Continue - } + }, Effect::Parallel(effects) => { // `dispatch_parallel` returns an explicit `BoxFuture<'_, _>` @@ -487,9 +488,13 @@ async fn dispatch_effect( payload, ) .await - } + }, - Effect::When { condition, body, source } => { + 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. @@ -517,9 +522,13 @@ async fn dispatch_effect( } } EffectOutcome::Continue - } + }, - Effect::Pdp { call, on_allow, on_deny } => { + 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 { @@ -548,7 +557,7 @@ async fn dispatch_effect( } } EffectOutcome::Continue - } + }, deny @ Decision::Deny { .. } => { // Reactions can override the PDP's deny reason // (e.g. `on_deny: [deny "..."]`) but cannot @@ -575,14 +584,14 @@ async fn dispatch_effect( } } EffectOutcome::Halt(deny) - } + }, }, Err(e) => EffectOutcome::Halt(Decision::Deny { reason: Some(format!("PDP error: {}", e)), rule_source: format!("pdp:{:?}", call.dialect), }), } - } + }, } } @@ -635,113 +644,113 @@ fn dispatch_parallel<'a>( 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). - 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); - branches.push(Box::pin(async move { - let mut branch_taints: 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, - phase, - &mut branch_taints, - &mut branch_args_modified, - &mut branch_result_modified, - &mut branch_payload, - )) - .await; - (outcome, branch_taints) - })); - } + use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; - // `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: &(EffectOutcome, Vec)| { - 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)) => { - taints.extend(branch_taints); - if first_halt.is_none() { - if let EffectOutcome::Halt(d) = effect_outcome { - first_halt = Some(d); + 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). + 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); + branches.push(Box::pin(async move { + let mut branch_taints: 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, + phase, + &mut branch_taints, + &mut branch_args_modified, + &mut branch_result_modified, + &mut branch_payload, + )) + .await; + (outcome, branch_taints) + })); + } + + // `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: &(EffectOutcome, Vec)| { + 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)) => { + taints.extend(branch_taints); + 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); + }, + 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, - } + match first_halt { + Some(d) => EffectOutcome::Halt(d), + None => EffectOutcome::Continue, + } }) } @@ -777,7 +786,10 @@ async fn dispatch_field_op( // 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 } + enum Side { + Args, + Result, + } let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") { if !matches!(phase, DispatchPhase::Pre) { return EffectOutcome::Continue; @@ -805,7 +817,9 @@ async fn dispatch_field_op( return EffectOutcome::Continue; // missing field → silent no-op }; - let pipeline = crate::pipeline::Pipeline { stages: stages.to_vec() }; + let pipeline = crate::pipeline::Pipeline { + stages: stages.to_vec(), + }; let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, phase).await; taints.extend(eval.taints); let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side { @@ -819,14 +833,17 @@ async fn dispatch_field_op( 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 { + }, + FieldOutcome::Deny { + reason, + stage_index: _, + } => EffectOutcome::Halt(Decision::Deny { reason: Some(reason), rule_source: fallback_source.to_string(), }), @@ -903,12 +920,15 @@ pub async fn evaluate_pipeline( 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)), + reason: format!( + "len(...) requires string value, got {}", + value_kind(¤t) + ), stage_index: idx, }, taints, @@ -924,12 +944,15 @@ pub async fn evaluate_pipeline( 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)), + reason: format!( + "range requires integer value, got {}", + value_kind(¤t) + ), stage_index: idx, }, taints, @@ -944,12 +967,15 @@ pub async fn evaluate_pipeline( 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)), + reason: format!( + "enum(...) requires string value, got {}", + value_kind(¤t) + ), stage_index: idx, }, taints, @@ -964,7 +990,7 @@ pub async fn evaluate_pipeline( taints, }; } - } + }, Stage::Regex { pattern } => { // Compile-at-eval for now. A future step can swap to a // route-level pre-compile cache keyed by pattern. @@ -978,12 +1004,15 @@ pub async fn evaluate_pipeline( }, taints, }; - } + }, }; let Some(s) = current.as_str() else { return PipelineEvaluation { outcome: FieldOutcome::Deny { - reason: format!("regex requires string value, got {}", value_kind(¤t)), + reason: format!( + "regex requires string value, got {}", + value_kind(¤t) + ), stage_index: idx, }, taints, @@ -998,7 +1027,7 @@ pub async fn evaluate_pipeline( taints, }; } - } + }, Stage::Validate { name } => { // Named-validator dispatch is not implemented in this // build. The parser rejects `validate(...)` at compile @@ -1017,14 +1046,17 @@ pub async fn evaluate_pipeline( }, taints, }; - } + }, // ----- Transforms ----- 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)), + reason: format!( + "mask(...) requires string value, got {}", + value_kind(¤t) + ), stage_index: idx, }, taints, @@ -1033,12 +1065,13 @@ pub async fn evaluate_pipeline( 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) + 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, @@ -1048,10 +1081,13 @@ pub async fn evaluate_pipeline( current = serde_json::Value::String("[REDACTED]".into()); replaced = true; } - } + }, Stage::Omit => { - return PipelineEvaluation { outcome: FieldOutcome::Omit, taints }; - } + return PipelineEvaluation { + outcome: FieldOutcome::Omit, + taints, + }; + }, Stage::Hash => { // Simple deterministic digest — DefaultHasher is fine for // de-identification (not for cryptographic use). @@ -1060,12 +1096,15 @@ pub async fn evaluate_pipeline( value_for_hash(¤t).hash(&mut h); current = serde_json::Value::String(format!("hash:{:016x}", h.finish())); replaced = true; - } + }, // ----- Effects ----- Stage::Taint { label, scopes } => { - taints.push(TaintEvent { label: label.clone(), scopes: scopes.clone() }); - } + taints.push(TaintEvent { + label: label.clone(), + scopes: scopes.clone(), + }); + }, Stage::Plugin { name } => { let invocation = PluginInvocation::Field { name: field_name, @@ -1082,20 +1121,22 @@ pub async fn evaluate_pipeline( current = new_value; replaced = true; } - } - Decision::Deny { reason, rule_source: _ } => { + }, + Decision::Deny { + reason, + rule_source: _, + } => { return PipelineEvaluation { outcome: FieldOutcome::Deny { - reason: reason.unwrap_or_else( - || format!("plugin `{}` denied", name), - ), + 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 { @@ -1105,9 +1146,9 @@ pub async fn evaluate_pipeline( }, taints, }; - } + }, } - } + }, Stage::Scan { kind } => { // Spec mapping (apl-dsl-spec §4): scan stages are taint // emitters. The actual PII detection / injection signal @@ -1127,7 +1168,7 @@ pub async fn evaluate_pipeline( current = serde_json::Value::String("[REDACTED]".into()); replaced = true; } - } + }, } } @@ -1139,15 +1180,18 @@ pub async fn evaluate_pipeline( 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::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), } } @@ -1155,11 +1199,21 @@ fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool { 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; } + 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; }, + 8 | 13 | 18 | 23 => { + if b != b'-' { + return false; + } + }, + _ => { + if !b.is_ascii_hexdigit() { + return false; + } + }, } } true @@ -1186,7 +1240,7 @@ fn value_for_hash(v: &serde_json::Value) -> String { #[cfg(test)] mod tests { use super::*; - use crate::rules::{ Condition, Expression, Rule}; + use crate::rules::{Condition, Expression, Rule}; use crate::step::{DelegationInvoker, NoopDelegationInvoker}; use std::collections::HashSet; use std::sync::Arc; @@ -1210,7 +1264,10 @@ mod tests { } fn deny(reason: &str) -> Effect { - Effect::Deny { reason: Some(reason.into()), code: None } + Effect::Deny { + reason: Some(reason.into()), + code: None, + } } fn cond(c: Condition) -> Expression { @@ -1232,15 +1289,26 @@ mod tests { 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"), + 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 } => { + Decision::Deny { + reason, + rule_source, + } => { assert_eq!(reason.as_deref(), Some("first")); assert_eq!(rule_source, "r0"); - } + }, d => panic!("expected Deny, got {:?}", d), } } @@ -1253,8 +1321,16 @@ mod tests { 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"), + 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) { @@ -1267,7 +1343,9 @@ mod tests { fn unmatched_rules_dont_fire() { let mut bag = AttributeBag::new(); // "denied" missing → false let rules = vec![rule( - cond(Condition::IsTrue { key: "denied".into() }), + cond(Condition::IsTrue { + key: "denied".into(), + }), deny("shouldn't fire"), "r0", )]; @@ -1279,8 +1357,18 @@ mod tests { #[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)); + assert!(!eval_condition( + &Condition::IsTrue { + key: "missing".into() + }, + &bag + )); + assert!(eval_condition( + &Condition::IsFalse { + key: "missing".into() + }, + &bag + )); // Comparison on missing → false (spec §2.6). assert!(!eval_condition( &Condition::Comparison { @@ -1301,10 +1389,22 @@ mod tests { 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::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)); } @@ -1441,8 +1541,12 @@ mod tests { // = 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() }), + cond(Condition::IsFalse { + key: "role.hr".into(), + }), + cond(Condition::IsFalse { + key: "role.finance".into(), + }), ]), deny("not in hr/finance"), "r1", @@ -1455,7 +1559,9 @@ mod tests { op: CompareOp::Gt, value: 2_i64.into(), }), - cond(Condition::IsTrue { key: "include_ssn".into() }), + cond(Condition::IsTrue { + key: "include_ssn".into(), + }), ]), deny("delegation too deep for SSN"), "r2", @@ -1493,7 +1599,16 @@ mod tests { v: &serde_json::Value, bag: &AttributeBag, ) -> FieldOutcome { - evaluate_pipeline(p, v, bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await.outcome + 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` @@ -1508,7 +1623,10 @@ mod tests { _bag: &AttributeBag, _invocation: PluginInvocation<'_>, ) -> Result { - panic!("NullPipelinePlugins should not dispatch; got plugin({})", name); + panic!( + "NullPipelinePlugins should not dispatch; got plugin({})", + name + ); } } @@ -1516,19 +1634,28 @@ mod tests { 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); + 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); + assert_eq!( + run_pipeline(&p, &json!("hello"), &bag).await, + FieldOutcome::Pass + ); match run_pipeline(&p, &json!(42), &bag).await { - FieldOutcome::Deny { reason, stage_index } => { + FieldOutcome::Deny { + reason, + stage_index, + } => { assert!(reason.contains("expected Str")); assert_eq!(stage_index, 0); - } + }, other => panic!("expected Deny, got {:?}", other), } } @@ -1572,7 +1699,9 @@ mod tests { 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) }]); + 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), @@ -1586,7 +1715,9 @@ mod tests { 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) }]); + 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, @@ -1602,7 +1733,10 @@ mod tests { // This stage should never run. Stage::Type(TypeCheck::Int), ]); - assert_eq!(run_pipeline(&p, &json!("anything"), &bag).await, FieldOutcome::Omit); + assert_eq!( + run_pipeline(&p, &json!("anything"), &bag).await, + FieldOutcome::Omit + ); } #[tokio::test] @@ -1610,15 +1744,24 @@ mod tests { let mut bag = AttributeBag::new(); let p = make_pipeline(vec![ Stage::Type(TypeCheck::Int), - Stage::Range { min: Some(0), max: Some(1_000_000) }, + Stage::Range { + min: Some(0), + max: Some(1_000_000), + }, ]); - assert_eq!(run_pipeline(&p, &json!(500_000), &bag).await, FieldOutcome::Pass); + 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 } => { + FieldOutcome::Deny { + reason, + stage_index, + } => { assert!(reason.contains("outside")); assert_eq!(stage_index, 1); - } + }, other => panic!("expected Deny, got {:?}", other), } } @@ -1626,8 +1769,14 @@ mod tests { #[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); + 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 { .. }, @@ -1640,7 +1789,10 @@ mod tests { 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_eq!( + run_pipeline(&p, &json!("medium"), &bag).await, + FieldOutcome::Pass + ); assert!(matches!( run_pipeline(&p, &json!("extreme"), &bag).await, FieldOutcome::Deny { .. }, @@ -1670,7 +1822,7 @@ mod tests { let s = v.as_str().unwrap(); assert!(s.starts_with("hash:")); assert_eq!(s.len(), "hash:".len() + 16); - } + }, other => panic!("expected Replace, got {:?}", other), } } @@ -1685,11 +1837,16 @@ mod tests { let mut bag = AttributeBag::new(); let p = make_pipeline(vec![ Stage::Type(TypeCheck::Str), - Stage::Validate { name: "ssn_format".into() }, + 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 } => { + FieldOutcome::Deny { + reason, + stage_index, + } => { assert_eq!(stage_index, 1, "validate stage is at index 1"); assert!( reason.contains("not implemented"), @@ -1699,7 +1856,7 @@ mod tests { reason.contains("regex") || reason.contains("plugin"), "deny reason should point at alternatives: {reason}", ); - } + }, other => panic!("expected Deny on validate(...) stage, got {:?}", other), } } @@ -1709,7 +1866,7 @@ mod tests { // 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::Type(TypeCheck::Int), // will fail on a string Stage::Mask { keep_last: 4 }, ]); match run_pipeline(&p, &json!("hello"), &bag).await { @@ -1726,7 +1883,10 @@ mod tests { 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); + assert_eq!( + run_pipeline(&p, &json!("123-45-6789"), &bag).await, + FieldOutcome::Pass + ); } #[tokio::test] @@ -1736,10 +1896,13 @@ mod tests { pattern: r"^\d{3}-\d{2}-\d{4}$".into(), }]); match run_pipeline(&p, &json!("not an ssn"), &bag).await { - FieldOutcome::Deny { reason, stage_index } => { + FieldOutcome::Deny { + reason, + stage_index, + } => { assert!(reason.contains("did not match")); assert_eq!(stage_index, 0); - } + }, other => panic!("expected Deny, got {:?}", other), } } @@ -1747,11 +1910,13 @@ mod tests { #[tokio::test] async fn pipeline_regex_invalid_pattern_denies() { let mut bag = AttributeBag::new(); - let p = make_pipeline(vec![Stage::Regex { pattern: "(unclosed".into() }]); + 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), } } @@ -1759,11 +1924,13 @@ mod tests { #[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() }]); + 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), } } @@ -1775,35 +1942,72 @@ mod tests { let mut bag = AttributeBag::new(); let p = make_pipeline(vec![ Stage::Type(TypeCheck::Str), - Stage::Taint { label: "PII".into(), scopes: vec![TaintScope::Session] }, + 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; + 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], - }]); + 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; + 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], - }]); + 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; + 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"); @@ -1812,8 +2016,18 @@ mod tests { #[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; + 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"); } @@ -1824,16 +2038,33 @@ mod tests { // 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] }, + 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; + 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], - }]); + assert_eq!( + result.taints, + vec![TaintEvent { + label: "before".into(), + scopes: vec![TaintScope::Session], + }] + ); } // ----- Plugin stage in pipe chain ----- @@ -1861,16 +2092,27 @@ mod tests { 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()), - ]), + 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::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; + 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()); } @@ -1879,52 +2121,83 @@ mod tests { 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 { + 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], + 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 { + 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() }, + 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; + let result = evaluate_pipeline( + &p, + &json!("data"), + &bag, + &plugins, + "payload", + crate::step::DispatchPhase::Pre, + ) + .await; match result.outcome { - FieldOutcome::Deny { reason, stage_index } => { + FieldOutcome::Deny { + reason, + stage_index, + } => { assert_eq!(reason, "policy violation"); assert_eq!(stage_index, 0); - } + }, other => panic!("expected Deny, got {:?}", other), } } @@ -1932,9 +2205,21 @@ mod tests { #[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; + 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), @@ -1950,10 +2235,25 @@ mod tests { 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)); + 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)); + assert!(!eval_condition( + &Condition::Exists { + key: "args.nonexistent".into() + }, + &bag + )); } #[test] @@ -1965,18 +2265,24 @@ mod tests { 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)); + 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)); + assert!(!eval_condition( + &Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }, + &bag + )); } #[test] @@ -1989,11 +2295,14 @@ mod tests { ); // 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)); + assert!(eval_condition( + &Condition::InSet { + value_key: "subject.type".into(), + set_key: "blocked_types".into(), + negate: true, + }, + &bag + )); } #[test] @@ -2001,16 +2310,22 @@ mod tests { let mut bag = AttributeBag::new(); // Both missing → in = false → not in = true (spec §2.6 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)); + 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] @@ -2024,7 +2339,10 @@ mod tests { let mut bag = AttributeBag::new(); let r = Rule { condition: Expression::Always, - effects: vec![Effect::Deny { reason: Some("unconditional".into()), code: None }], + effects: vec![Effect::Deny { + reason: Some("unconditional".into()), + code: None, + }], source: "test".into(), }; match evaluate_rules(&[r], &bag) { @@ -2038,8 +2356,8 @@ mod tests { // =================================================================== use crate::step::{ - PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, - PluginError, PluginInvocation, PluginInvoker, PluginOutcome, + PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation, + PluginInvoker, PluginOutcome, }; use async_trait::async_trait; @@ -2051,13 +2369,18 @@ mod tests { } #[async_trait] impl PdpResolver for FakePdp { - fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } async fn evaluate( &self, _call: &PdpCall, _bag: &AttributeBag, ) -> Result { - Ok(PdpDecision { decision: self.decision.clone(), diagnostics: vec![] }) + Ok(PdpDecision { + decision: self.decision.clone(), + diagnostics: vec![], + }) } } @@ -2065,7 +2388,9 @@ mod tests { struct ErroringPdp; #[async_trait] impl PdpResolver for ErroringPdp { - fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } async fn evaluate( &self, _call: &PdpCall, @@ -2131,7 +2456,18 @@ mod tests { 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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await; + let r = evaluate_effects( + &steps, + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await; assert_eq!(r.decision, Decision::Allow); } @@ -2139,9 +2475,21 @@ mod tests { 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 }); + let pdp: Arc = Arc::new(FakePdp { + decision: Decision::Allow, + }); assert_eq!( - evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + evaluate_effects( + &steps, + &mut bag, + &pdp, + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null) + ) + .await + .decision, Decision::Allow, ); } @@ -2151,9 +2499,23 @@ mod tests { 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() }, + decision: Decision::Deny { + reason: Some("forbidden".into()), + rule_source: "pdp".into(), + }, }); - match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + match evaluate_effects( + &steps, + &mut bag, + &pdp, + &null_plugins(), + &noop_delegations(), + 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), } @@ -2165,22 +2527,45 @@ mod tests { // 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 }, + 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 }], + 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() }, + decision: Decision::Deny { + reason: Some("pdp original".into()), + rule_source: "p".into(), + }, }); - match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { - Decision::Deny { reason, rule_source } => { + match evaluate_effects( + &steps, + &mut bag, + &pdp, + &null_plugins(), + &noop_delegations(), + 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), } } @@ -2191,16 +2576,35 @@ mod tests { // 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 }, + 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 }], + 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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + let pdp: Arc = Arc::new(FakePdp { + decision: Decision::Allow, + }); + match evaluate_effects( + &steps, + &mut bag, + &pdp, + &null_plugins(), + &noop_delegations(), + 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), } @@ -2210,10 +2614,21 @@ mod tests { 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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + match evaluate_effects( + &steps, + &mut bag, + &(Arc::new(ErroringPdp) as Arc), + &null_plugins(), + &noop_delegations(), + 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), } } @@ -2224,24 +2639,58 @@ mod tests { 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(), - }), + ( + "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() }]; + 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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + evaluate_effects( + &allow_only, + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow + }) as Arc), + &plugins, + &noop_delegations(), + 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() }, + 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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + match evaluate_effects( + &with_deny, + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &plugins, + &noop_delegations(), + 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), } @@ -2250,13 +2699,33 @@ mod tests { #[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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { - Decision::Deny { reason, rule_source } => { + 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(), + 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), } } @@ -2272,11 +2741,25 @@ mod tests { // 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 }], + 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(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await; + let eval = evaluate_effects( + &steps, + &mut bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + 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), @@ -2286,7 +2769,10 @@ mod tests { // 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]); + assert_eq!( + eval.taints[0].scopes, + vec![crate::pipeline::TaintScope::Session] + ); } // ----- E2: FieldOp end-to-end through evaluate_steps ----- @@ -2316,7 +2802,9 @@ mod tests { let eval = evaluate_effects( &steps, &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2333,7 +2821,10 @@ mod tests { Some("[REDACTED]") ); // Other fields untouched. - assert_eq!(payload.args.get("name").and_then(|v| v.as_str()), Some("Jane")); + assert_eq!( + payload.args.get("name").and_then(|v| v.as_str()), + Some("Jane") + ); } #[tokio::test] @@ -2355,7 +2846,9 @@ mod tests { let eval = evaluate_effects( &vec![Effect::from(rule)], &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2386,7 +2879,9 @@ mod tests { let eval = evaluate_effects( &vec![Effect::from(rule)], &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2394,10 +2889,13 @@ mod tests { ) .await; match eval.decision { - Decision::Deny { reason, rule_source } => { + 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), } } @@ -2428,7 +2926,9 @@ mod tests { let eval = evaluate_effects( &vec![Effect::from(rule)], &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2437,12 +2937,15 @@ mod tests { .await; match eval.decision { - Decision::Deny { reason, rule_source } => { + 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), } } @@ -2468,7 +2971,9 @@ mod tests { let eval = evaluate_effects( &vec![Effect::from(rule)], &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2503,7 +3008,9 @@ mod tests { let eval = evaluate_effects( &vec![Effect::from(rule)], &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2514,7 +3021,7 @@ mod tests { match eval.decision { Decision::Deny { reason, .. } => { assert_eq!(reason.as_deref(), Some("branch 1 denied")); - } + }, other => panic!("expected Deny, got {:?}", other), } } @@ -2545,7 +3052,9 @@ mod tests { let eval = evaluate_effects( &vec![Effect::from(rule)], &mut bag, - &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, @@ -2556,7 +3065,7 @@ mod tests { match eval.decision { Decision::Deny { reason, .. } => { assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins"); - } + }, other => panic!("expected Deny, got {:?}", other), } } diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs index 46ee9647..48d12e7c 100644 --- a/crates/apl-core/src/lib.rs +++ b/crates/apl-core/src/lib.rs @@ -24,7 +24,7 @@ pub mod step; pub use attributes::{AttributeBag, AttributeExtractor, AttributeValue}; pub use evaluator::{ - evaluate_pipeline, evaluate_rules, evaluate_effects, Decision, FieldOutcome, PipelineEvaluation, + evaluate_effects, evaluate_pipeline, evaluate_rules, Decision, FieldOutcome, PipelineEvaluation, }; pub use parser::{ compile_config, compile_policy_block_value, parse_pipeline, parse_predicate, parse_rule, diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index 04e4752b..fc7a0f19 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -56,27 +56,27 @@ pub enum ParseError { #[derive(Debug, Clone, PartialEq)] enum Tok { - Ident(String), // dotted: subject.id, role.hr, authenticated + 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, // ! + 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 + Contains, // keyword + Require, // keyword + Exists, // keyword + In, // keyword — set membership operator } struct Lexer<'a> { @@ -87,7 +87,11 @@ struct Lexer<'a> { impl<'a> Lexer<'a> { fn new(src: &'a str) -> Self { - Self { src, bytes: src.as_bytes(), pos: 0 } + Self { + src, + bytes: src.as_bytes(), + pos: 0, + } } fn peek(&self) -> Option { @@ -102,7 +106,11 @@ impl<'a> Lexer<'a> { fn skip_ws(&mut self) { while let Some(b) = self.peek() { - if b.is_ascii_whitespace() { self.pos += 1; } else { break; } + if b.is_ascii_whitespace() { + self.pos += 1; + } else { + break; + } } } @@ -110,38 +118,67 @@ impl<'a> Lexer<'a> { let mut out = Vec::new(); loop { self.skip_ws(); - let Some(b) = self.peek() else { return Ok(out); }; + 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; + 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 + 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 + 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 } - } + 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 } - } + 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(), @@ -155,7 +192,9 @@ impl<'a> Lexer<'a> { self.bump(); // opening quote let start = self.pos; while let Some(b) = self.peek() { - if b == quote { break; } + if b == quote { + break; + } self.pos += 1; } if self.peek() != Some(quote) { @@ -170,24 +209,36 @@ impl<'a> Lexer<'a> { fn lex_number(&mut self) -> Result { let start = self.pos; - if self.peek() == Some(b'-') { self.pos += 1; } + if self.peek() == Some(b'-') { + self.pos += 1; + } while let Some(b) = self.peek() { - if b.is_ascii_digit() { self.pos += 1; } else { break; } + 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; } + 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) + text.parse::() + .map(Tok::FloatLit) .map_err(|_| self.err(&format!("bad float `{}`", text))) } else { - text.parse::().map(Tok::IntLit) + text.parse::() + .map(Tok::IntLit) .map_err(|_| self.err(&format!("bad int `{}`", text))) } } @@ -195,7 +246,11 @@ impl<'a> Lexer<'a> { fn lex_ident_or_keyword(&mut self) -> Tok { let start = self.pos; while let Some(b) = self.peek() { - if is_ident_cont(b) { self.pos += 1; } else { break; } + if is_ident_cont(b) { + self.pos += 1; + } else { + break; + } } let s = &self.src[start..self.pos]; match s { @@ -244,12 +299,17 @@ impl<'a> PredParser<'a> { 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..]))); + 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 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; @@ -268,7 +328,11 @@ impl<'a> PredParser<'a> { self.bump(); parts.push(self.parse_and()?); } - Ok(if parts.len() == 1 { parts.pop().unwrap() } else { Expression::Or(parts) }) + Ok(if parts.len() == 1 { + parts.pop().unwrap() + } else { + Expression::Or(parts) + }) } fn parse_and(&mut self) -> Result { @@ -277,7 +341,11 @@ impl<'a> PredParser<'a> { self.bump(); parts.push(self.parse_unary()?); } - Ok(if parts.len() == 1 { parts.pop().unwrap() } else { Expression::And(parts) }) + Ok(if parts.len() == 1 { + parts.pop().unwrap() + } else { + Expression::And(parts) + }) } fn parse_unary(&mut self) -> Result { @@ -298,7 +366,7 @@ impl<'a> PredParser<'a> { Some(Tok::RParen) => Ok(inner), _ => Err(self.err("expected `)`")), } - } + }, // `require(...)` is a rule-level shorthand per DSL §8 grammar // (`rule = require_call | predicate ...`), not a sub-predicate. // Trying to nest it inside `&` / `|` is a grammar error. @@ -317,20 +385,26 @@ impl<'a> PredParser<'a> { fn parse_exists(&mut self) -> Result { self.bump(); // exists match self.bump() { - Some(Tok::LParen) => {} + 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, - ))), + 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, - ))), + Some(Tok::RParen) => {}, + other => { + return Err(self.err(&format!( + "expected `)` after exists() argument, got {:?}", + other, + ))) + }, } Ok(Expression::Condition(Condition::Exists { key })) } @@ -396,23 +470,33 @@ impl<'a> PredParser<'a> { "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 })) + 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, - ))), + 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 })) + Ok(Expression::Condition(Condition::InSet { + value_key, + set_key, + negate, + })) } } @@ -449,7 +533,10 @@ pub fn parse_rule(line: &str, source: &str) -> Result { let condition = parse_require_rule(trimmed)?; return Ok(Rule::single( condition, - Effect::Deny { reason: None, code: None }, + Effect::Deny { + reason: None, + code: None, + }, source, )); } @@ -488,17 +575,26 @@ pub fn parse_rule(line: &str, source: &str) -> Result { }); } // DSL §2 default: bare predicate denies. - (trimmed, vec![Effect::Deny { reason: None, code: None }]) - } + ( + 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), - })?; + 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() }) + Ok(Rule { + condition, + effects, + source: source.to_string(), + }) } fn is_require_call(s: &str) -> bool { @@ -523,11 +619,11 @@ fn parse_require_rule(line: &str) -> Result { }; match iter.next() { - Some(Tok::Require) => {} + Some(Tok::Require) => {}, _ => return Err(bad("expected `require`")), } match iter.next() { - Some(Tok::LParen) => {} + Some(Tok::LParen) => {}, _ => return Err(bad("expected `(` after `require`")), } @@ -545,25 +641,32 @@ fn parse_require_rule(line: &str) -> Result { 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", - )), + 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, - ))), + }, + 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")); + return Err(bad( + "trailing tokens after `require(...)` — require is a complete rule", + )); } let falses: Vec = keys @@ -574,15 +677,26 @@ fn parse_require_rule(line: &str) -> Result { return Ok(falses.into_iter().next().unwrap()); } Ok(match sep { - Some(Tok::Or) => Expression::And(falses), // require(X | Y) → !X & !Y - _ => Expression::Or(falses), // require(X, Y) → !X | !Y + 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:"] { + 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(':')); } @@ -602,12 +716,12 @@ fn split_predicate_action(s: &str) -> Option<(&str, &str)> { for (i, &b) in bytes.iter().enumerate() { match (in_quote, b) { (Some(q), c) if c == q => in_quote = None, - (Some(_), _) => {} + (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())) @@ -643,7 +757,10 @@ fn parse_action(s: &str, rule: &str) -> Result, ParseError> { fn try_bare_action(s: &str) -> Option> { match s.trim() { - "deny" => Some(vec![Effect::Deny { reason: None, code: None }]), + "deny" => Some(vec![Effect::Deny { + reason: None, + code: None, + }]), "allow" => Some(vec![Effect::Allow]), _ => None, } @@ -701,7 +818,6 @@ fn strip_string_literal(s: &str, rule: &str) -> Result { } } - // ===================================================================== // Step parser (policy: / post_policy: entries — supports steps + rules) // ===================================================================== @@ -734,11 +850,10 @@ fn parse_step_string(line: &str, source: &str) -> Result { // 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 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 { @@ -768,7 +883,9 @@ fn parse_step_string(line: &str, source: &str) -> Result { msg: format!("`{verb}(...)`: plugin name must not be empty"), }); } - return Ok(Step::Plugin { name: name.to_string() }); + return Ok(Step::Plugin { + name: name.to_string(), + }); } // delegate(name, key: value, key: [a, b], ...) — emit as Step::Delegate. @@ -778,11 +895,10 @@ fn parse_step_string(line: &str, source: &str) -> Result { // 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 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, @@ -823,10 +939,7 @@ struct ParsedDelegateCall { /// 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 { +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}"), @@ -861,15 +974,13 @@ fn parse_delegate_call_args( 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` \ + 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() { @@ -904,11 +1015,10 @@ fn parse_delegate_call_args( ), }); } - let value = - parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { - rule: kwarg.to_string(), - msg: format!("{source}: `{key}`: {msg}"), - })?; + 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); } @@ -958,20 +1068,20 @@ fn split_top_level_commas(input: &str) -> Result, String> { '"' | '\'' => { 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") - })?; + 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), } } @@ -994,8 +1104,7 @@ fn parse_delegate_value(s: &str) -> Result { 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(']')) - { + 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 { @@ -1049,10 +1158,7 @@ fn strip_wrapping_quotes(s: &str) -> &str { s } -fn parse_step_map( - m: &serde_yaml::Mapping, - source: &str, -) -> Result { +fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result { // Canonical structured rule: `- when: X\n do: Y` (DSL §3.2). // Detected by the presence of *both* `when` and `do` keys — order // doesn't matter, and the map can carry extra keys for future @@ -1116,7 +1222,7 @@ fn parse_step_map( effects: vec![effect], source: source.to_string(), })); - } + }, "parallel" => { let effect = parse_parallel_effect(body_val, source)?; return Ok(Step::Rule(Rule { @@ -1124,8 +1230,8 @@ fn parse_step_map( effects: vec![effect], source: source.to_string(), })); - } - _ => {} + }, + _ => {}, } // Split the key into "dialect" + optional "(args)" portion. @@ -1147,7 +1253,10 @@ fn parse_step_map( // 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), + 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)?; @@ -1186,10 +1295,7 @@ fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool { /// 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" - ) + matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel") } /// Parse the canonical `- when: X` `do: Y` rule form (DSL §3.2). `Y` @@ -1197,10 +1303,7 @@ fn is_known_pdp_dialect(key: &str) -> bool { /// 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 { +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 @@ -1281,10 +1384,7 @@ fn parse_shorthand_multi_effect( /// 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> { +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 @@ -1295,7 +1395,7 @@ fn parse_do_body( // 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(), @@ -1306,10 +1406,7 @@ fn parse_do_body( /// 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 { +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) => { @@ -1322,7 +1419,7 @@ fn parse_effect_value( match key_str.trim() { "sequential" => return parse_sequential_effect(v, source), "parallel" => return parse_parallel_effect(v, source), - _ => {} + _ => {}, } } } @@ -1330,7 +1427,7 @@ fn parse_effect_value( // `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(), @@ -1340,10 +1437,7 @@ fn parse_effect_value( /// 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 { +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(), @@ -1364,10 +1458,7 @@ fn parse_sequential_effect( /// 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 { +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(), @@ -1491,8 +1582,8 @@ fn find_top_level_pipe(s: &str) -> Option { continue; } return Some(i); - } - _ => {} + }, + _ => {}, } i += 1; } @@ -1503,7 +1594,10 @@ fn find_top_level_pipe(s: &str) -> Option { /// `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 { + let Some(rest) = s + .strip_prefix("args.") + .or_else(|| s.strip_prefix("result.")) + else { return false; }; !rest.is_empty() @@ -1529,7 +1623,11 @@ pub(crate) fn step_to_top_level_effect(step: Step) -> Result body: rule.effects, source: rule.source, }), - Step::Pdp { call, on_allow, on_deny } => { + Step::Pdp { + call, + on_allow, + on_deny, + } => { let on_allow = on_allow .into_iter() .map(step_to_top_level_effect) @@ -1538,8 +1636,12 @@ pub(crate) fn step_to_top_level_effect(step: Step) -> Result .into_iter() .map(step_to_top_level_effect) .collect::, _>>()?; - Ok(Effect::Pdp { call, on_allow, on_deny }) - } + Ok(Effect::Pdp { + call, + on_allow, + on_deny, + }) + }, Step::Plugin { name } => Ok(Effect::Plugin { name }), Step::Delegate(d) => Ok(Effect::Delegate(d)), Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), @@ -1573,7 +1675,7 @@ fn step_to_effect(step: Step, source: &str) -> Result { }); } Ok(rule.effects.into_iter().next().unwrap()) - } + }, Step::Pdp { .. } => Err(ParseError::Rule { rule: source.to_string(), msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \ @@ -1587,10 +1689,7 @@ fn step_to_effect(step: Step, source: &str) -> Result { /// 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 { +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 \ @@ -1665,14 +1764,14 @@ fn extract_pdp_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()); - } + }, } } @@ -1725,8 +1824,8 @@ fn extract_call_args(line: &str, name: &str) -> Option { } return None; } - } - _ => {} + }, + _ => {}, } } None @@ -1763,15 +1862,15 @@ fn split_top_level(s: &str, delim: u8) -> Vec<&str> { for (i, &b) in bytes.iter().enumerate() { match (in_quote, b) { (Some(q), c) if c == q => in_quote = None, - (Some(_), _) => {} + (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..]); @@ -1792,8 +1891,7 @@ fn parse_stage(src: &str) -> Result { // 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"))?; + let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?; match (head, args.as_deref()) { // ----- Bare validators / transforms / effects ----- @@ -1808,24 +1906,34 @@ fn parse_stage(src: &str) -> Result { ("omit", None) => Ok(Stage::Omit), ("hash", None) => Ok(Stage::Hash), // Scan placeholders parse as bare identifiers (DSL §4.5). - ("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 }), + ("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, + }), // ----- Parameterized ----- ("mask", Some(a)) => { - let n: usize = a.trim().parse() + let n: usize = a + .trim() + .parse() .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?; Ok(Stage::Mask { keep_last: n }) - } + }, ("redact", Some(a)) => { // 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) }) - } + Ok(Stage::Redact { + condition: Some(cond), + }) + }, ("hash", Some(_)) => Err(bad("hash takes no arguments")), ("omit", Some(_)) => Err(bad( "omit takes no arguments — for conditional omit, use a policy rule predicate", @@ -1834,14 +1942,17 @@ fn parse_stage(src: &str) -> 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) } + 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()?, }) - } + }, ("enum", Some(a)) => { let values = split_top_level(a, b',') .into_iter() @@ -1862,7 +1973,7 @@ fn parse_stage(src: &str) -> Result { return Err(bad("enum() requires at least one value")); } Ok(Stage::Enum { values }) - } + }, ("regex", Some(a)) => { let pattern = a.trim(); let pat = if (pattern.starts_with('"') && pattern.ends_with('"')) @@ -1873,7 +1984,7 @@ fn parse_stage(src: &str) -> Result { pattern.to_string() }; Ok(Stage::Regex { pattern: pat }) - } + }, ("validate", Some(a)) => { // Named-validator dispatch (`validate(name)`) is in the // spec (DSL §4.2) but not implemented in this build — @@ -1895,7 +2006,7 @@ fn parse_stage(src: &str) -> Result { a.trim(), a.trim(), ))) - } + }, // `run` is an alias for `plugin` (mirrors the policy-step alias). ("plugin" | "run", Some(a)) => { let name = a.trim(); @@ -1903,10 +2014,14 @@ fn parse_stage(src: &str) -> Result { // 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"))); + return Err(bad(&format!( + "`{head}(...)`: plugin name must not be empty" + ))); } - Ok(Stage::Plugin { name: name.to_string() }) - } + Ok(Stage::Plugin { + name: name.to_string(), + }) + }, ("taint", Some(a)) => parse_taint(a, src), (other, _) => Err(bad(&format!("unknown stage `{}`", other))), @@ -1933,8 +2048,16 @@ 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)?) }; + 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 } @@ -1969,14 +2092,19 @@ fn split_head_args(s: &str) -> Option<(&str, Option)> { b'(' => depth += 1, b')' => { depth -= 1; - if depth == 0 { close = Some(i); break; } - } - _ => {} + if depth == 0 { + close = Some(i); + break; + } + }, + _ => {}, } } let close = close?; let head = s[..open].trim(); - if head.is_empty() { return None; } + 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() { @@ -1986,7 +2114,11 @@ fn split_head_args(s: &str) -> Option<(&str, Option)> { } } else { let head = s.trim(); - if head.is_empty() { None } else { Some((head, None)) } + if head.is_empty() { + None + } else { + Some((head, None)) + } } } @@ -2031,7 +2163,10 @@ fn parse_taint_scope(s: &str, src: &str) -> Result { "message" => Ok(TaintScope::Message), other => Err(ParseError::Predicate { predicate: src.to_string(), - msg: format!("unknown taint scope `{}` (expected `session` or `message`)", other), + msg: format!( + "unknown taint scope `{}` (expected `session` or `message`)", + other + ), }), } } @@ -2228,11 +2363,14 @@ mod tests { #[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), - ]); + assert_eq!( + toks, + vec![ + Tok::Ident("delegation.depth".into()), + Tok::Gt, + Tok::IntLit(2), + ] + ); } #[test] @@ -2245,13 +2383,20 @@ mod tests { #[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()), - ]); + 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] @@ -2265,7 +2410,12 @@ mod tests { #[test] fn pred_bare_identifier() { let e = parse_predicate("authenticated").unwrap(); - assert_eq!(e, Expression::Condition(Condition::IsTrue { key: "authenticated".into() })); + assert_eq!( + e, + Expression::Condition(Condition::IsTrue { + key: "authenticated".into() + }) + ); } #[test] @@ -2302,10 +2452,10 @@ mod tests { Expression::Or(parts) => { assert_eq!(parts.len(), 2); match &parts[0] { - Expression::And(_) => {} + Expression::And(_) => {}, other => panic!("first OR branch should be AND, got {:?}", other), } - } + }, other => panic!("top-level should be OR, got {:?}", other), } } @@ -2319,7 +2469,7 @@ mod tests { assert_eq!(parts.len(), 2); matches!(parts[0], Expression::Or(_)); matches!(parts[1], Expression::Not(_)); - } + }, other => panic!("expected top-level AND, got {:?}", other), } } @@ -2336,10 +2486,18 @@ mod tests { fn rule_require_single_arg_desugars_to_isfalse_and_deny() { // require(X) → Rule { condition: IsFalse(X), action: Deny } (DSL §8.1) let r = parse_rule("require(authenticated)", "test").unwrap(); - assert!(matches!(r.effects.as_slice(), [Effect::Deny { reason: None, code: None }])); + assert!(matches!( + r.effects.as_slice(), + [Effect::Deny { + reason: None, + code: None + }] + )); assert_eq!( r.condition, - Expression::Condition(Condition::IsFalse { key: "authenticated".into() }), + Expression::Condition(Condition::IsFalse { + key: "authenticated".into() + }), ); } @@ -2351,8 +2509,12 @@ mod tests { assert_eq!( r.condition, Expression::Or(vec![ - Expression::Condition(Condition::IsFalse { key: "role.hr".into() }), - Expression::Condition(Condition::IsFalse { key: "perm.view_ssn".into() }), + Expression::Condition(Condition::IsFalse { + key: "role.hr".into() + }), + Expression::Condition(Condition::IsFalse { + key: "perm.view_ssn".into() + }), ]), ); } @@ -2365,8 +2527,12 @@ mod tests { assert_eq!( r.condition, Expression::And(vec![ - Expression::Condition(Condition::IsFalse { key: "role.finance".into() }), - Expression::Condition(Condition::IsFalse { key: "role.admin".into() }), + Expression::Condition(Condition::IsFalse { + key: "role.finance".into() + }), + Expression::Condition(Condition::IsFalse { + key: "role.admin".into() + }), ]), ); } @@ -2418,7 +2584,9 @@ mod tests { let e = parse_predicate("exists(args.amount)").unwrap(); assert_eq!( e, - Expression::Condition(Condition::Exists { key: "args.amount".into() }), + Expression::Condition(Condition::Exists { + key: "args.amount".into() + }), ); } @@ -2431,9 +2599,11 @@ mod tests { assert_eq!(parts.len(), 2); assert_eq!( parts[0], - Expression::Condition(Condition::Exists { key: "args.amount".into() }), + Expression::Condition(Condition::Exists { + key: "args.amount".into() + }), ); - } + }, other => panic!("expected And, got {:?}", other), } } @@ -2457,11 +2627,11 @@ mod tests { fn rule_predicate_action_form() { let r = parse_rule("delegation.depth > 2: deny", "test").unwrap(); match r.effects.as_slice() { - [Effect::Deny { .. }] => {} + [Effect::Deny { .. }] => {}, other => panic!("expected [Deny], got {:?}", other), } match r.condition { - Expression::Condition(Condition::Comparison { .. }) => {} + Expression::Condition(Condition::Comparison { .. }) => {}, other => panic!("expected Comparison, got {:?}", other), } } @@ -2485,7 +2655,13 @@ mod tests { // Expression::Always as the predicate (DSL §3.1). 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 }])); + 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); @@ -2500,17 +2676,26 @@ mod tests { 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), + [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) }] => { + [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), } } @@ -2522,17 +2707,25 @@ mod tests { let err = parse_rule("deny(unquoted)", "test").unwrap_err(); assert!( matches!(err, ParseError::Rule { .. }), - "expected ParseError::Rule, got {:?}", err + "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)"] { + 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 + "expected UnsupportedStep for `{}`, got {:?}", + s, + err ); } } @@ -2568,10 +2761,13 @@ mod tests { ) .unwrap(); match r.effects.as_slice() { - [Effect::Deny { reason: Some(reason), code: Some(code) }] => { + [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), } } @@ -2609,9 +2805,12 @@ mod tests { )); assert!(matches!( rule.effects.as_slice(), - [Effect::Deny { reason: None, code: None }] + [Effect::Deny { + reason: None, + code: None + }] )); - } + }, other => panic!("expected Step::Rule, got {:?}", other), } } @@ -2627,10 +2826,13 @@ mod tests { panic!("expected Step::Rule"); }; match rule.effects.as_slice() { - [Effect::Deny { reason: Some(r), code: Some(c) }] => { + [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), } } @@ -2657,10 +2859,13 @@ do: Effect::Taint { ref label, .. } if label == "unauth" )); match &rule.effects[2] { - Effect::Deny { reason: Some(r), code: Some(c) } => { + 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), } } @@ -2668,8 +2873,7 @@ do: #[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(); + let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap(); assert!(matches!(step, Step::Rule(_))); } @@ -2778,7 +2982,7 @@ do: Effect::FieldOp { path, stages } => { assert_eq!(path, "result.salary"); assert_eq!(stages.len(), 1, "single `redact` stage"); - } + }, other => panic!("expected FieldOp, got {:?}", other), } } @@ -2798,7 +3002,7 @@ do: "args.card_number | mask(4)" [Effect::FieldOp { path, stages }] => { assert_eq!(path, "args.card_number"); assert_eq!(stages.len(), 1); - } + }, other => panic!("expected single FieldOp, got {:?}", other), } } @@ -2819,7 +3023,7 @@ do: "args.card_number | str | mask(4)" [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), } } @@ -2843,7 +3047,7 @@ do: "args.card_number | run(luhn)" [Stage::Plugin { name }] => assert_eq!(name, "luhn"), other => panic!("expected [Stage::Plugin], got {:?}", other), } - } + }, other => panic!("expected single FieldOp, got {:?}", other), } } @@ -2883,8 +3087,8 @@ do: "args.card_number | run(luhn)" !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]), "bare `role.hr` must NOT parse as a FieldOp path" ); - } - Err(_) => {} // also fine + }, + Err(_) => {}, // also fine other => panic!("unexpected: {:?}", other), } } @@ -2944,7 +3148,7 @@ sequential: 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), } } @@ -2963,7 +3167,7 @@ parallel: match rule.effects.as_slice() { [Effect::Parallel(inner)] => { assert_eq!(inner.len(), 2); - } + }, other => panic!("expected single Parallel effect, got {:?}", other), } } @@ -3021,12 +3225,14 @@ sequential: - "plugin(audit)" "#; let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { panic!("expected Rule") }; + 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), } } @@ -3055,7 +3261,9 @@ sequential: - "plugin(nemo)" "#; let step = parse_step_yaml(yaml).unwrap(); - let Step::Rule(rule) = step else { panic!("expected Rule") }; + let Step::Rule(rule) = step else { + panic!("expected Rule") + }; let Effect::Sequential(outer) = &rule.effects[0] else { panic!("expected Sequential"); }; @@ -3072,10 +3280,7 @@ sequential: #[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(); + 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())); @@ -3099,7 +3304,9 @@ routes: 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)); + assert!(route + .declared_phases() + .contains(crate::rules::Phase::Policy)); } #[test] @@ -3118,7 +3325,10 @@ routes: "#; 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"); + assert!( + !routes.contains_key("legacy"), + "legacy route should be omitted, not compiled" + ); } #[test] @@ -3154,7 +3364,8 @@ routes: let msg = format!("{}", err); assert!( msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"), - "error message should reference the failure: {}", msg, + "error message should reference the failure: {}", + msg, ); } @@ -3205,7 +3416,10 @@ routes: } // 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}"); + assert!( + format!("{err}").contains("run("), + "error should name `run(...)`: {err}" + ); } #[test] @@ -3222,7 +3436,7 @@ routes: Effect::Taint { label, scopes } => { assert_eq!(label, "audit"); assert_eq!(scopes, &vec![TaintScope::Session]); - } + }, other => panic!("expected Effect::Taint, got {:?}", other), } } @@ -3245,7 +3459,11 @@ routes: 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 } => { + 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). @@ -3255,7 +3473,7 @@ routes: 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), } } @@ -3276,7 +3494,11 @@ routes: 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 } => { + 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()))); @@ -3284,7 +3506,7 @@ routes: 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), } } @@ -3308,7 +3530,7 @@ routes: // 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), } } @@ -3326,7 +3548,7 @@ 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), } } @@ -3346,8 +3568,7 @@ routes: 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 pdp: std::sync::Arc = std::sync::Arc::new(NullPdpResolver); let plugins: std::sync::Arc = std::sync::Arc::new(NullPluginInvoker); let delegations: std::sync::Arc = @@ -3359,16 +3580,41 @@ routes: bag.set("role.hr", true); bag.set("delegation.depth", 1_i64); assert_eq!( - crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + crate::evaluate_effects( + &route.policy, + &mut bag, + &pdp, + &plugins, + &delegations, + 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, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + match crate::evaluate_effects( + &route.policy, + &mut bag, + &pdp, + &plugins, + &delegations, + crate::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await + .decision + { Decision::Deny { rule_source, .. } => { - assert!(rule_source.contains("policy[2]"), "expected policy[2], got {}", rule_source); - } + assert!( + rule_source.contains("policy[2]"), + "expected policy[2], got {}", + rule_source + ); + }, d => panic!("expected Deny, got {:?}", d), } @@ -3376,10 +3622,25 @@ routes: 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, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + match crate::evaluate_effects( + &route.policy, + &mut bag, + &pdp, + &plugins, + &delegations, + crate::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await + .decision + { Decision::Deny { rule_source, .. } => { - assert!(rule_source.contains("policy[1]"), "expected policy[1], got {}", rule_source); - } + assert!( + rule_source.contains("policy[1]"), + "expected policy[1], got {}", + rule_source + ); + }, d => panic!("expected Deny, got {:?}", d), } } @@ -3389,7 +3650,9 @@ routes: struct NullPdpResolver; #[async_trait::async_trait] impl crate::PdpResolver for NullPdpResolver { - fn dialect(&self) -> crate::PdpDialect { crate::PdpDialect::Cedar } + fn dialect(&self) -> crate::PdpDialect { + crate::PdpDialect::Cedar + } async fn evaluate( &self, _call: &crate::PdpCall, @@ -3429,16 +3692,22 @@ routes: #[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 }, - ]); + 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) }, - ]); + assert_eq!( + p.stages, + vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { + min: Some(0), + max: Some(1_000_000) + }, + ] + ); } #[test] @@ -3448,7 +3717,7 @@ routes: 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(_) } => {} + Stage::Redact { condition: Some(_) } => {}, other => panic!("expected Redact with condition, got {:?}", other), } } @@ -3456,33 +3725,75 @@ routes: #[test] fn pipeline_length_constraints() { let p = parse_pipeline("len(..500)").unwrap(); - assert_eq!(p.stages, vec![Stage::Length { min: None, max: Some(500) }]); + 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) }]); + 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 }]); + 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) }]); + 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) }]); + 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) }]); + 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()], - }]); + 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()], - }]); + assert_eq!( + p.stages, + vec![Stage::Enum { + values: vec!["a".into(), "b".into()], + }] + ); } #[test] @@ -3490,14 +3801,14 @@ routes: 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), - } - } + 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), } } @@ -3505,20 +3816,29 @@ routes: #[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], - }]); + 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], - }]); + 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], - }]); + assert_eq!( + p.stages, + vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Session, TaintScope::Message], + }] + ); } #[test] @@ -3557,8 +3877,14 @@ routes: // 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(_) })); + 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(); @@ -3646,7 +3972,10 @@ routes: 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()); + 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( @@ -3695,12 +4024,11 @@ policy: - "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"); + 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.policy[0]"); - } + }, other => panic!("expected When, got {:?}", other), } } @@ -3822,7 +4150,8 @@ policy: #[test] fn parse_delegate_string_with_string_kwargs() { - let yaml = r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#; + 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"); @@ -3874,7 +4203,8 @@ policy: // 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(), + cfg.get(serde_yaml::Value::String("on_error".into())) + .is_none(), "on_error must not appear in config_override" ); } @@ -3896,7 +4226,8 @@ policy: #[test] fn parse_delegate_string_quoted_value_preserves_internal_commas() { - let yaml = r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#; + 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"); @@ -3950,7 +4281,10 @@ policy: 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}"); + assert!( + msg.contains("unmatched") || msg.contains("unbalanced"), + "got: {msg}" + ); } #[test] diff --git a/crates/apl-core/src/pipeline.rs b/crates/apl-core/src/pipeline.rs index 0eae7b1c..2514da83 100644 --- a/crates/apl-core/src/pipeline.rs +++ b/crates/apl-core/src/pipeline.rs @@ -68,24 +68,40 @@ pub enum Stage { Type(TypeCheck), /// `regex("pattern")` — parser captures the pattern; evaluator stubbed /// until we add the `regex` crate dependency. - Regex { pattern: String }, + Regex { + pattern: String, + }, /// `validate(name)` — named validator dispatch; evaluator stubbed. - Validate { name: String }, + Validate { + name: String, + }, /// `len(..N)`, `len(N..M)`, `len(N..)` — string length bounds. - Length { min: Option, max: Option }, + Length { + min: Option, + max: Option, + }, /// Bare range literal `N..M`, `..M`, `N..`, with optional `k`/`K`/`m`/`M` /// numeric suffixes. Integer-only per DSL §4.3. - Range { min: Option, max: Option }, + Range { + min: Option, + max: Option, + }, /// `enum(a, b, c)` — value must equal one of the listed strings. - Enum { values: Vec }, + Enum { + values: Vec, + }, // ----- Transforms (produce a new value) ----- /// `mask(N)` — replace all but last N chars with `*`. - Mask { keep_last: usize }, + 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 }, + Redact { + condition: Option, + }, /// `omit` — drop the field from output entirely. No conditional form /// per DSL §4.1 — use a policy rule for conditional omit. Omit, @@ -93,9 +109,16 @@ pub enum Stage { Hash, // ----- Effects (deferred to step 5c — IR captured, eval stubbed) ----- - Taint { label: String, scopes: Vec }, - Plugin { name: String }, - Scan { kind: ScanKind }, + Taint { + label: String, + scopes: Vec, + }, + Plugin { + name: String, + }, + Scan { + kind: ScanKind, + }, } /// Sequence of stages applied to one field's value. @@ -106,9 +129,15 @@ pub struct Pipeline { } 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() } + 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. diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs index 30dff509..52841d7c 100644 --- a/crates/apl-core/src/route.rs +++ b/crates/apl-core/src/route.rs @@ -27,7 +27,7 @@ use std::sync::Arc; use crate::attributes::AttributeBag; -use crate::evaluator::{evaluate_pipeline, evaluate_effects, Decision, FieldOutcome}; +use crate::evaluator::{evaluate_effects, evaluate_pipeline, Decision, FieldOutcome}; use crate::pipeline::TaintEvent; use crate::rules::CompiledRoute; use crate::step::{DelegationInvoker, DispatchPhase, PdpResolver, PluginInvoker}; @@ -47,7 +47,10 @@ impl RoutePayload { } pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self { - Self { args, result: Some(result) } + Self { + args, + result: Some(result), + } } } @@ -100,18 +103,21 @@ pub async fn evaluate_pre( .await; taints.extend(eval.taints); match eval.outcome { - FieldOutcome::Pass => {} + 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: _ } => { + }, + FieldOutcome::Deny { + reason, + stage_index: _, + } => { return RouteDecision { decision: Decision::Deny { reason: Some(reason), @@ -121,7 +127,7 @@ pub async fn evaluate_pre( args_modified, result_modified: false, }; - } + }, } } @@ -183,18 +189,21 @@ pub async fn evaluate_post( .await; taints.extend(eval.taints); match eval.outcome { - FieldOutcome::Pass => {} + 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: _ } => { + }, + FieldOutcome::Deny { + reason, + stage_index: _, + } => { return RouteDecision { decision: Decision::Deny { reason: Some(reason), @@ -204,7 +213,7 @@ pub async fn evaluate_post( args_modified: false, result_modified, }; - } + }, } } } @@ -272,7 +281,10 @@ pub async fn evaluate_route( /// 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. -pub(crate) fn get_dotted<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { +pub(crate) 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)?; @@ -283,7 +295,11 @@ pub(crate) fn get_dotted<'a>(root: &'a serde_json::Value, path: &str) -> Option< /// 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 { +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, @@ -291,8 +307,12 @@ pub(crate) fn set_dotted(root: &mut serde_json::Value, path: &str, value: serde_ }; let mut cur = root; for seg in parents { - let Some(next) = cur.get_mut(*seg) else { return false; }; - if !next.is_object() { return false; } + 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 { @@ -312,8 +332,12 @@ pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool { }; let mut cur = root; for seg in parents { - let Some(next) = cur.get_mut(*seg) else { return false; }; - if !next.is_object() { return false; } + 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 { @@ -340,13 +364,18 @@ mod tests { struct AllowPdp; #[async_trait] impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } async fn evaluate( &self, _call: &PdpCall, _bag: &AttributeBag, ) -> Result { - Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) } } @@ -387,7 +416,10 @@ mod tests { fn deny_rule(source: &str, reason: &str) -> Rule { Rule::single( Expression::Always, - Effect::Deny { reason: Some(reason.into()), code: None }, + Effect::Deny { + reason: Some(reason.into()), + code: None, + }, source, ) } @@ -399,7 +431,15 @@ mod tests { 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(!r.args_modified); assert!(!r.result_modified); @@ -409,10 +449,20 @@ mod tests { #[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 }])); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(r.args_modified); assert_eq!(payload.args["ssn"], json!("*******6789")); @@ -425,21 +475,38 @@ mod tests { "amount", vec![ Stage::Type(TypeCheck::Int), - Stage::Range { min: Some(0), max: Some(100) }, + 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"))); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => { - assert!(rule_source.contains("amount"), "expected args rule source, got {}", rule_source); - } + assert!( + rule_source.contains("amount"), + "expected args rule source, got {}", + rule_source + ); + }, d => panic!("expected Deny from args phase, got {:?}", d), } } @@ -449,10 +516,21 @@ mod tests { // 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)])); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(!r.args_modified); } @@ -463,7 +541,15 @@ mod tests { 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(r.args_modified); assert!(payload.args.get("secret").is_none()); @@ -473,13 +559,25 @@ mod tests { #[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"))); + 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 }])); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"), d => panic!("expected policy deny, got {:?}", d), @@ -492,10 +590,20 @@ mod tests { #[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 }])); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(!r.result_modified); } @@ -503,13 +611,21 @@ mod tests { #[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 }])); + 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()).await; + 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(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(r.result_modified); let result = payload.result.as_ref().unwrap(); @@ -523,19 +639,31 @@ mod tests { // args emits a taint route.args.push(field_rule( "input", - vec![Stage::Taint { label: "args_seen".into(), scopes: vec![TaintScope::Session] }], + 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] }], + 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()).await; + 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(), + ) + .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"]); @@ -552,7 +680,15 @@ mod tests { 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(r.args_modified); assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789")); @@ -562,11 +698,22 @@ mod tests { #[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 }])); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(!r.args_modified); } @@ -575,12 +722,24 @@ mod tests { 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"))); + 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"), d => panic!("expected post_policy deny, got {:?}", d), @@ -633,26 +792,38 @@ mod tests { // 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" }), - ); + 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()).await; + let r = evaluate_pre( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); - assert!(r.args_modified, "args mask stage should have rewritten the field"); + 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")); + assert_eq!( + payload.result.as_ref().unwrap()["ssn"], + json!("555-12-3456") + ); } #[tokio::test] @@ -660,19 +831,25 @@ mod tests { // 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" }), - ); + 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()).await; + let r = evaluate_post( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .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"); @@ -686,21 +863,38 @@ mod tests { 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)])); + 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 }, + 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()).await; + let r = evaluate_pre( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => { - assert!(rule_source.contains("test.id"), "args denial got source {}", rule_source); - } + assert!( + rule_source.contains("test.id"), + "args denial got source {}", + rule_source + ); + }, d => panic!("expected args-side Deny, got {:?}", d), } } @@ -712,28 +906,39 @@ mod tests { let mut route = CompiledRoute::new("test"); route.policy.push(Effect::from(Rule::single( Expression::Always, - Effect::Deny { reason: Some("policy_deny".into()), code: None }, + Effect::Deny { + reason: Some("policy_deny".into()), + code: None, + }, "test.policy[0]", ))); - route.result.push(field_rule("ssn", vec![ - Stage::Redact { condition: None }, - ])); + 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 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()).await; + let r = evaluate_route( + &route, + &mut bag, + &mut payload, + &pdp_arc(), + &plugins(), + &delegations(), + ) + .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")); + 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 index 7fd707aa..557765fa 100644 --- a/crates/apl-core/src/rules.rs +++ b/crates/apl-core/src/rules.rs @@ -44,11 +44,31 @@ pub enum Literal { 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) } } +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. /// @@ -62,19 +82,33 @@ impl From for Literal { fn from(v: String) -> Self { Literal::String(v) /// an `Action::Deny`. See DSL spec §8.1 desugarings. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Condition { - Comparison { key: String, op: CompareOp, value: Literal }, - IsTrue { key: String }, - IsFalse { key: String }, + 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). Per DSL §2.2. - Exists { key: String }, + 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`. Per DSL §2.4. 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 }, + InSet { + value_key: String, + set_key: String, + negate: bool, + }, } /// Compound predicate. @@ -219,16 +253,17 @@ impl Effect { Effect::FieldOp { .. } | Effect::Delegate(_) => 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, .. } => { + 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 { .. } => false, + }, + Effect::Allow | Effect::Deny { .. } | Effect::Plugin { .. } | Effect::Taint { .. } => { + false + }, } } @@ -253,25 +288,27 @@ impl Effect { 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, .. } => { + }, + Effect::Pdp { + on_allow, on_deny, .. + } => { for e in on_allow.iter().chain(on_deny.iter()) { e.validate_parallel_purity()?; } Ok(()) - } + }, _ => Ok(()), } } @@ -342,7 +379,9 @@ pub enum Phase { pub struct PhaseSet(u8); impl PhaseSet { - pub fn new() -> Self { Self(0) } + pub fn new() -> Self { + Self(0) + } pub fn insert(&mut self, p: Phase) { self.0 |= Self::bit(p); @@ -352,7 +391,9 @@ impl PhaseSet { self.0 & Self::bit(p) != 0 } - pub fn is_empty(&self) -> bool { self.0 == 0 } + pub fn is_empty(&self) -> bool { + self.0 == 0 + } fn bit(p: Phase) -> u8 { match p { @@ -397,16 +438,27 @@ pub struct CompiledRoute { impl CompiledRoute { pub fn new(route_key: impl Into) -> Self { - Self { route_key: route_key.into(), ..Default::default() } + 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); } + 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 } @@ -452,8 +504,11 @@ impl CompiledRoute { 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(); + 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); @@ -506,7 +561,10 @@ mod tests { op: CompareOp::Gt, value: 2_i64.into(), }), - effects: vec![Effect::Deny { reason: Some("too deep".into()), code: None }], + effects: vec![Effect::Deny { + reason: Some("too deep".into()), + code: None, + }], source: "policy[0]".into(), }; if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { @@ -520,7 +578,9 @@ mod tests { fn rule_serde_roundtrip() { let r = Rule { condition: Expression::And(vec![ - Expression::Condition(Condition::IsTrue { key: "authenticated".into() }), + Expression::Condition(Condition::IsTrue { + key: "authenticated".into(), + }), Expression::Condition(Condition::Comparison { key: "delegation.depth".into(), op: CompareOp::LtEq, @@ -604,12 +664,16 @@ mod tests { let mut effective = CompiledRoute::new("route.X"); effective.args.push(FieldRule { field: "id".into(), - pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Str)] }, + 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)] }, + pipeline: Pipeline { + stages: vec![Stage::Type(TypeCheck::Str)], + }, source: "default.args.trace_id".into(), }); @@ -617,7 +681,9 @@ mod tests { let mut route_layer = CompiledRoute::new("ignored"); route_layer.args.push(FieldRule { field: "id".into(), - pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Uuid)] }, + pipeline: Pipeline { + stages: vec![Stage::Type(TypeCheck::Uuid)], + }, source: "route.args.id".into(), }); @@ -626,10 +692,17 @@ mod tests { 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!(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(); + let trace = effective + .args + .iter() + .find(|f| f.field == "trace_id") + .unwrap(); assert_eq!(trace.source, "default.args.trace_id"); } @@ -641,31 +714,44 @@ mod tests { let mut effective = CompiledRoute::new("route.X"); effective.plugin_overrides.insert( "rate_limiter".into(), - PluginOverride { on_error: Some("ignore".into()), ..Default::default() }, + PluginOverride { + on_error: Some("ignore".into()), + ..Default::default() + }, ); effective.plugin_overrides.insert( "audit_logger".into(), - PluginOverride { on_error: Some("ignore".into()), ..Default::default() }, + 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() }, + 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(), + 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(), + effective.plugin_overrides["audit_logger"] + .on_error + .as_deref(), Some("ignore"), ); } @@ -736,8 +822,12 @@ mod tests { 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::Plugin { + name: "rate_limiter".into(), + }, + Effect::Plugin { + name: "audit".into(), + }, Effect::Allow, ]); assert!(effect.validate_parallel_purity().is_ok()); @@ -748,7 +838,9 @@ mod tests { // 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::Plugin { + name: "audit".into(), + }, Effect::FieldOp { path: "args.ssn".into(), stages: vec![], @@ -782,9 +874,10 @@ mod tests { path: "args.x".into(), stages: vec![], }]); - let outer = Effect::Parallel(vec![ - Effect::Sequential(vec![Effect::Allow, inner_parallel]), - ]); + let outer = Effect::Parallel(vec![Effect::Sequential(vec![ + Effect::Allow, + inner_parallel, + ])]); assert!(outer.validate_parallel_purity().is_err()); } @@ -807,7 +900,11 @@ mod tests { // 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::Deny { + reason: None, + code: None + } + .contains_mutation()); assert!(!Effect::Plugin { name: "x".into() }.contains_mutation()); assert!(!Effect::Taint { label: "x".into(), diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index a3f05e9c..9a434dd7 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -69,7 +69,10 @@ pub(crate) enum Step { /// `taint(label[, scope])` — apply a taint label. Always succeeds; /// never produces a Deny. SessionStore dispatch happens in apl-cpex. - Taint { label: String, scopes: Vec }, + Taint { + label: String, + scopes: Vec, + }, } /// One delegation invocation inside `policy:` or `post_policy:`. @@ -329,10 +332,7 @@ pub trait DelegationInvoker: Send + Sync { /// `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; + async fn delegate(&self, step: &DelegateStep) -> Result; } /// What a delegation invocation returned. @@ -390,10 +390,7 @@ pub struct NoopDelegationInvoker; #[async_trait] impl DelegationInvoker for NoopDelegationInvoker { - async fn delegate( - &self, - step: &DelegateStep, - ) -> Result { + async fn delegate(&self, step: &DelegateStep) -> Result { Err(DelegationError::NotFound(step.plugin_name.clone())) } } @@ -433,7 +430,11 @@ pub struct PluginOutcome { 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 } + Self { + decision: Decision::Allow, + taints: vec![], + modified_value: None, + } } } @@ -465,10 +466,14 @@ pub enum PluginError { impl Step { /// Wrap a `Rule` as a `Step`. Saves typing in tests and parser code. - pub fn rule(r: Rule) -> Self { Step::Rule(r) } + 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(_)) } + pub fn is_rule(&self) -> bool { + matches!(self, Step::Rule(_)) + } } /// Bag keys the delegation step writes after a successful dispatch. diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs index 6227aaab..d38c25de 100644 --- a/crates/apl-core/tests/yaml_end_to_end.rs +++ b/crates/apl-core/tests/yaml_end_to_end.rs @@ -54,13 +54,18 @@ routes: struct AllowPdp; #[async_trait] impl PdpResolver for AllowPdp { - fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } async fn evaluate( &self, _call: &PdpCall, _bag: &AttributeBag, ) -> Result { - Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) } } @@ -100,9 +105,20 @@ async fn alice_full_access_sees_unredacted_result_with_masked_id() { }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); - assert!(r.args_modified == false, "args has only a `str` validator, no mutation"); + 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(); @@ -134,7 +150,15 @@ async fn mallory_no_perm_no_role_gets_both_fields_redacted() { }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); let result = payload.result.as_ref().unwrap(); @@ -160,17 +184,32 @@ async fn deep_delegation_denies_at_policy() { json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => { - assert!(rule_source.contains("policy"), "got source: {}", rule_source); - } + assert!( + rule_source.contains("policy"), + "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")); + assert_eq!( + payload.result.as_ref().unwrap()["employee_id"], + json!("123-45-6789") + ); } #[tokio::test] @@ -187,7 +226,15 @@ async fn unauthenticated_user_is_denied_before_args_mutate_result() { json!({ "ssn": "999-99-9999", "salary": 50000, "employee_id": "123-45-6789" }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; assert!(matches!(r.decision, Decision::Deny { .. })); assert!(!r.result_modified); } @@ -208,7 +255,15 @@ async fn args_validator_rejects_wrong_type() { json!({ "ssn": "x", "salary": 1, "employee_id": "x" }), ); - let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; match r.decision { Decision::Deny { rule_source, .. } => { assert!( @@ -216,7 +271,7 @@ async fn args_validator_rejects_wrong_type() { "expected args field source, got {}", rule_source, ); - } + }, d => panic!("expected args-phase deny, got {:?}", d), } // Result phase didn't run. @@ -235,7 +290,15 @@ async fn inbound_only_evaluation_skips_result_phase() { 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()).await; + let r = evaluate_route( + route, + &mut bag, + &mut payload, + &pdp(), + &plugins(), + &delegations(), + ) + .await; assert_eq!(r.decision, Decision::Allow); assert!(!r.result_modified); assert!(payload.result.is_none()); diff --git a/crates/apl-cpex/Cargo.toml b/crates/apl-cpex/Cargo.toml index c859afeb..8fb9a396 100644 --- a/crates/apl-cpex/Cargo.toml +++ b/crates/apl-cpex/Cargo.toml @@ -22,11 +22,17 @@ 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 = { path = "../apl-core" } -apl-cmf = { path = "../apl-cmf" } -cpex-core = { path = "../cpex-core" } +apl-core = { path = "../apl-core", version = "0.2.0" } +apl-cmf = { path = "../apl-cmf", version = "0.2.0" } +cpex-core = { path = "../cpex-core", version = "0.2.0" } async-trait = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } @@ -42,3 +48,6 @@ sha2 = "0.10" [dev-dependencies] serde = { workspace = true } + +[lints] +workspace = true diff --git a/crates/apl-cpex/src/cmf_invoker.rs b/crates/apl-cpex/src/cmf_invoker.rs index b30a7fef..34f400ef 100644 --- a/crates/apl-cpex/src/cmf_invoker.rs +++ b/crates/apl-cpex/src/cmf_invoker.rs @@ -339,7 +339,7 @@ impl PluginInvoker for CmfPluginInvoker { )), PluginInvocation::Step { .. } => None, } - } + }, None => { tracing::warn!( plugin = %plugin_name, @@ -347,7 +347,7 @@ impl PluginInvoker for CmfPluginInvoker { (downcast failed) — dropping the mutation" ); None - } + }, } } else { None diff --git a/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs index c4447d47..e280bd21 100644 --- a/crates/apl-cpex/src/delegation_invoker.rs +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -93,10 +93,7 @@ impl DelegationPluginInvoker { #[async_trait] impl DelegationInvoker for DelegationPluginInvoker { - async fn delegate( - &self, - step: &DelegateStep, - ) -> Result { + async fn delegate(&self, step: &DelegateStep) -> Result { // 1. Resolve the plugin's token.delegate entry from the plan. // Routes that don't reference this plugin in `policy:` / // `post_policy:` at compile time won't have it in the plan @@ -136,10 +133,7 @@ impl DelegationInvoker for DelegationPluginInvoker { // downstream call is for); `audience`, `permissions`, // `mode`, `auth_enforced_by` are recognized; everything // else stays opaque. - let cfg = step - .config_override - .as_ref() - .and_then(|v| v.as_mapping()); + let cfg = step.config_override.as_ref().and_then(|v| v.as_mapping()); let target_name: String = cfg .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs index 3fbafdb9..1b14538a 100644 --- a/crates/apl-cpex/src/dispatch_plan.rs +++ b/crates/apl-cpex/src/dispatch_plan.rs @@ -97,8 +97,7 @@ impl RoutePluginEntry { self.entries_by_hook .iter() .find(|(hook_name, _)| { - lookup_hook_metadata(hook_name) - .matches(requested_entity_type, requested_phase) + lookup_hook_metadata(hook_name).matches(requested_entity_type, requested_phase) }) .map(|(_, entry)| entry) } @@ -148,7 +147,7 @@ impl RouteDispatchPlan { "APL route references plugin not in `plugins:` block — skipping", ); continue; - } + }, }; // Pull the three overrideable values off the effective view. @@ -156,12 +155,14 @@ impl RouteDispatchPlan { // 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 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); @@ -256,10 +257,7 @@ impl RouteDispatchPlan { /// 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 { + 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; @@ -297,11 +295,13 @@ fn walk_effects(effects: &[Effect], visit: &mut F) { 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, .. } => { + Effect::Pdp { + on_allow, on_deny, .. + } => { walk_effects(on_allow, visit); walk_effects(on_deny, visit); - } - _ => {} + }, + _ => {}, } } } diff --git a/crates/apl-cpex/src/parallel_safety.rs b/crates/apl-cpex/src/parallel_safety.rs index 2550927b..43357a60 100644 --- a/crates/apl-cpex/src/parallel_safety.rs +++ b/crates/apl-cpex/src/parallel_safety.rs @@ -114,17 +114,17 @@ fn walk_effect( 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 @@ -132,18 +132,20 @@ fn walk_effect( for e in body { walk_effect(e, location, under_parallel, registry, errors); } - } - Effect::Pdp { on_allow, on_deny, .. } => { + }, + 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. - _ => {} + _ => {}, } } @@ -161,7 +163,7 @@ fn check_plugin_mode( location, name )); return; - } + }, }; if !is_safe_in_parallel(mode) { errors.push(format!( diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs index 7dab81e7..03c4769e 100644 --- a/crates/apl-cpex/src/pdp_router.rs +++ b/crates/apl-cpex/src/pdp_router.rs @@ -110,11 +110,7 @@ impl PdpResolver for PdpRouter { PdpDialect::Custom("router".to_string()) } - async fn evaluate( - &self, - call: &PdpCall, - bag: &AttributeBag, - ) -> Result { + async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result { let resolver = self .resolvers .get(&call.dialect) @@ -213,7 +209,10 @@ mod tests { dialect: PdpDialect::Cedar, args: serde_yaml::Value::Null, }; - let res = router.evaluate(&call, &AttributeBag::default()).await.unwrap(); + let res = router + .evaluate(&call, &AttributeBag::default()) + .await + .unwrap(); assert!(matches!(res.decision, Decision::Allow)); } } diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index 2ab2ce39..16cf4cf6 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -229,7 +229,7 @@ impl AnyHookHandler for AplRouteHandler { "session state could not be loaded", )), })); - } + }, }; // Build the attribute bag. APL predicates read flat keys; the @@ -263,7 +263,7 @@ impl AnyHookHandler for AplRouteHandler { // 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 @@ -321,7 +321,7 @@ impl AnyHookHandler for AplRouteHandler { &delegations_dyn, ) .await - } + }, Phase::Post => { evaluate_post( &self.route, @@ -332,7 +332,7 @@ impl AnyHookHandler for AplRouteHandler { &delegations_dyn, ) .await - } + }, }; // Drain Session-scoped taints (from `taint(label, session)` / @@ -421,7 +421,7 @@ impl AnyHookHandler for AplRouteHandler { }; let reason = reason.unwrap_or_else(|| "access denied".to_string()); (false, Some(PluginViolation::new(code, reason))) - } + }, }; // Append fail-closed (R18) with merge precedence: @@ -505,7 +505,7 @@ fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value { .map(|(k, v)| (k.clone(), v.clone())) .collect(), ); - } + }, ContentPart::PromptRequest { content } => { return Value::Object( content @@ -514,8 +514,8 @@ fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value { .map(|(k, v)| (k.clone(), v.clone())) .collect(), ); - } - _ => {} + }, + _ => {}, } } Value::String(msg.get_text_content()) @@ -539,14 +539,14 @@ fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) { 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. diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs index 1c7b123a..ccb07b17 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -454,7 +454,7 @@ impl ConfigVisitor for AplConfigVisitor { "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); @@ -569,7 +569,7 @@ impl ConfigVisitor for AplConfigVisitor { "APL visitor: no CMF hook pair for entity_type — skipping route", ); continue; - } + }, }; // Snapshot the active session store (a `global.apl.session_store` @@ -757,7 +757,9 @@ fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) { return; } let mut referenced: std::collections::HashSet = - crate::dispatch_plan::collect_plugin_names(route).into_iter().collect(); + 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) { @@ -890,20 +892,29 @@ mod tests { fn apl_wrapper_is_returned_as_is() { let v = yaml("apl:\n policy:\n - \"deny\"\n"); let block = apl_subblock(&v).expect("wrapper present"); - assert!(block.get("policy").is_some(), "wrapper block exposes policy"); + assert!( + block.get("policy").is_some(), + "wrapper block exposes policy" + ); } #[test] fn null_apl_wrapper_is_none() { let v = yaml("apl: null\n"); - assert!(apl_subblock(&v).is_none(), "explicit null apl => no contribution"); + assert!( + apl_subblock(&v).is_none(), + "explicit null apl => no contribution" + ); } #[test] fn flat_policy_without_wrapper_is_collected() { let v = yaml("tool: get_weather\npolicy:\n - \"deny\"\n"); let block = apl_subblock(&v).expect("flat policy recognized"); - assert!(block.get("policy").is_some(), "flat policy lifted into the block"); + assert!( + block.get("policy").is_some(), + "flat policy lifted into the block" + ); assert!( block.get("tool").is_none(), "structural keys must not leak into the apl block", @@ -917,7 +928,9 @@ mod tests { // 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"); + 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"), @@ -944,7 +957,10 @@ mod tests { #[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"); + assert!( + apl_subblock(&v).is_none(), + "no APL terms => no contribution" + ); } #[test] @@ -992,12 +1008,18 @@ mod tests { 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()), "policy step is referenced"); + assert!( + referenced.contains(&"used".to_string()), + "policy 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"); + 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/cmf_invoker_dispatch.rs b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs index c6f5c0ff..bb550d6d 100644 --- a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs +++ b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs @@ -288,7 +288,7 @@ async fn step_invocation_deny_surfaces_violation_reason_and_code() { } => { assert_eq!(reason.as_deref(), Some("test-fixture denied this call")); assert_eq!(rule_source, "policy.forbidden"); - } + }, other => panic!("expected Decision::Deny, got {:?}", other), } } @@ -732,7 +732,7 @@ async fn multi_hook_plugin_dispatches_per_phase_via_routing_table() { 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/delegate_step_e2e.rs b/crates/apl-cpex/tests/delegate_step_e2e.rs index c748691c..7a1dfc91 100644 --- a/crates/apl-cpex/tests/delegate_step_e2e.rs +++ b/crates/apl-cpex/tests/delegate_step_e2e.rs @@ -484,7 +484,7 @@ routes: 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!( diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs index 6df3831b..a3cbf019 100644 --- a/crates/apl-cpex/tests/end_to_end_route.rs +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -314,7 +314,7 @@ routes: PluginOutcome → evaluate_steps → RouteDecision" ); assert_eq!(rule_source, "policy.forbidden"); - } + }, other => panic!("expected Decision::Deny, got {:?}", other), } } diff --git a/crates/apl-cpex/tests/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs index 2eacfd45..a0d23ee5 100644 --- a/crates/apl-cpex/tests/visitor_e2e.rs +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -726,7 +726,9 @@ routes: .await; assert!(!result.continue_processing, "flat deny path should halt"); - let violation = result.violation.expect("deny path must surface a violation"); + let violation = result + .violation + .expect("deny path must surface a violation"); assert_eq!(violation.reason, "deny-gate fired"); } @@ -774,7 +776,9 @@ routes: !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"); + let violation = result + .violation + .expect("deny path must surface a violation"); assert_eq!(violation.reason, "deny-gate fired"); } diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml index 0aded972..b0dfdb36 100644 --- a/crates/cpex-builtins/Cargo.toml +++ b/crates/cpex-builtins/Cargo.toml @@ -20,6 +20,11 @@ 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 @@ -41,18 +46,21 @@ full = ["default", "valkey"] [dependencies] # Registration targets — the manager, PDP factory trait, and APL options. -cpex-core = { path = "../cpex-core" } -apl-core = { path = "../apl-core" } -apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core", version = "0.2.0" } +apl-core = { path = "../apl-core", version = "0.2.0" } +apl-cpex = { path = "../apl-cpex", version = "0.2.0" } # Builtin extension crates — each behind its feature. -cpex-plugin-pii-scanner = { path = "../../builtins/plugins/pii-scanner", optional = true } -cpex-plugin-audit-logger = { path = "../../builtins/plugins/audit-logger", optional = true } -cpex-plugin-identity-jwt = { path = "../../builtins/plugins/identity-jwt", optional = true } -cpex-plugin-delegator-oauth = { path = "../../builtins/plugins/delegator-oauth", optional = true } -cpex-pdp-cedar-direct = { path = "../../builtins/pdps/cedar-direct", optional = true } -cpex-pdp-cel = { path = "../../builtins/pdps/cel", optional = true } -cpex-session-valkey = { path = "../../builtins/session/valkey", optional = true } +cpex-plugin-pii-scanner = { path = "../../builtins/plugins/pii-scanner", version = "0.2.0", optional = true } +cpex-plugin-audit-logger = { path = "../../builtins/plugins/audit-logger", version = "0.2.0", optional = true } +cpex-plugin-identity-jwt = { path = "../../builtins/plugins/identity-jwt", version = "0.2.0", optional = true } +cpex-plugin-delegator-oauth = { path = "../../builtins/plugins/delegator-oauth", version = "0.2.0", optional = true } +cpex-pdp-cedar-direct = { path = "../../builtins/pdps/cedar-direct", version = "0.2.0", optional = true } +cpex-pdp-cel = { path = "../../builtins/pdps/cel", version = "0.2.0", optional = true } +cpex-session-valkey = { path = "../../builtins/session/valkey", version = "0.2.0", optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index abbd5e62..66ddcfda 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -14,6 +14,11 @@ 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 } @@ -37,4 +42,7 @@ chrono = { workspace = true } 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 = { path = "../cpex-orchestration" } +cpex-orchestration = { path = "../cpex-orchestration", version = "0.2.0" } + +[lints] +workspace = true diff --git a/crates/cpex-core/src/cmf/content.rs b/crates/cpex-core/src/cmf/content.rs index 3cde9b65..6c5e6f86 100644 --- a/crates/cpex-core/src/cmf/content.rs +++ b/crates/cpex-core/src/cmf/content.rs @@ -322,7 +322,7 @@ mod tests { assert_eq!(content.name, "get_weather"); assert_eq!(content.tool_call_id, "tc_001"); assert_eq!(content.arguments["city"], "London"); - } + }, _ => panic!("expected ToolCall variant"), } } @@ -343,7 +343,7 @@ mod tests { ContentPart::ToolResult { content } => { assert_eq!(content.tool_name, "get_weather"); assert!(!content.is_error); - } + }, _ => panic!("expected ToolResult variant"), } } @@ -365,7 +365,7 @@ mod tests { 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"), } } @@ -385,7 +385,7 @@ mod tests { ContentPart::ResourceRef { content } => { assert_eq!(content.uri, "db://users/42"); assert_eq!(content.resource_type, ResourceType::Database); - } + }, _ => panic!("expected ResourceRef variant"), } } @@ -405,7 +405,7 @@ mod tests { ContentPart::Image { content } => { assert_eq!(content.source_type, "url"); assert_eq!(content.data, "https://example.com/photo.jpg"); - } + }, _ => panic!("expected Image variant"), } } @@ -424,7 +424,7 @@ mod tests { match &part { ContentPart::PromptRequest { content } => { assert_eq!(content.name, "summarize"); - } + }, _ => panic!("expected PromptRequest variant"), } } diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs index 2c92e76d..23d5457c 100644 --- a/crates/cpex-core/src/cmf/view.rs +++ b/crates/cpex-core/src/cmf/view.rs @@ -228,7 +228,7 @@ impl<'a> MessageView<'a> { 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, diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index a0212bd6..b4e87f3c 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -380,14 +380,13 @@ where 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 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(|| { @@ -401,13 +400,13 @@ where .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))? .clone(); (replace_inherited, items) - } + }, _ => { return Err(D::Error::custom( "`identity:` must be a list of steps or an object with \ `steps:` (and optional `replace_inherited:`)", )); - } + }, }; let mut steps = Vec::with_capacity(raw_steps.len()); @@ -441,7 +440,7 @@ fn parse_identity_step( name, ..Default::default() }) - } + }, serde_yaml::Value::Mapping(_) => { // Lean on serde's derived Deserialize for the map shape — // `RouteIdentityStep` already handles `name` / `on_error` / @@ -459,12 +458,10 @@ fn parse_identity_step( #[serde(default, flatten)] extra: std::collections::HashMap, } - let parsed: StepYaml = serde_yaml::from_value(raw) - .map_err(|e| format!("identity step [{index}]: {e}"))?; + 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" - )); + return Err(format!("identity step [{index}] `name:` cannot be empty")); } Ok(RouteIdentityStep { name: parsed.name, @@ -472,7 +469,7 @@ fn parse_identity_step( 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:`)" @@ -1777,8 +1774,7 @@ routes: - corp-jwt "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None); + let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None); assert!(resolved.is_empty()); } @@ -1793,8 +1789,7 @@ routes: - rate_limiter "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); assert!(resolved.is_empty()); } @@ -1813,8 +1808,7 @@ routes: - agent-context "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + 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"]); } @@ -1836,15 +1830,17 @@ routes: audience: my-tool "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + 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")); + assert_eq!( + config.get("audience").and_then(|v| v.as_str()), + Some("my-tool") + ); } // ---- Slice C: global + tag-bundle inheritance ---- @@ -1865,8 +1861,7 @@ routes: - tool: get_weather "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + 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"]); } @@ -1891,8 +1886,7 @@ routes: - agent-context "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + 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"]); } @@ -1924,8 +1918,7 @@ routes: - agent-context "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None); + 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"]); } @@ -1958,8 +1951,7 @@ routes: - legacy-basic-auth "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None); + 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"]); } @@ -1984,8 +1976,7 @@ routes: steps: [] "#; let cfg = parse_config(yaml).unwrap(); - let resolved = - resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None); + let resolved = resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None); assert!(resolved.is_empty()); } @@ -2011,16 +2002,17 @@ routes: "#; let cfg = parse_config(yaml).unwrap(); - let tagged = - resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None); + 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"); + 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] @@ -2040,20 +2032,12 @@ routes: - corp-jwt "#; let cfg = parse_config(yaml).unwrap(); - let matching = resolve_identity_plugins_for_route( - &cfg, - "tool", - "get_weather", - Some("tenant-a"), - ); + 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"), - ); + let non_matching = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", Some("tenant-b")); assert!(non_matching.is_empty()); } diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs index 6328d088..f02d725d 100644 --- a/crates/cpex-core/src/delegation/payload.rs +++ b/crates/cpex-core/src/delegation/payload.rs @@ -242,10 +242,7 @@ impl DelegationPayload { /// 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 { + pub fn new(bearer_token: impl Into, target_name: impl Into) -> Self { Self { bearer_token: Zeroizing::new(bearer_token.into()), target_name: target_name.into(), @@ -534,8 +531,7 @@ mod tests { #[test] fn merge_overlays_outputs() { let mut base = DelegationPayload::new("tok", "tool"); - base.metadata - .insert("attempt".into(), serde_json::json!(1)); + base.metadata.insert("attempt".into(), serde_json::json!(1)); let mut overlay = DelegationPayload::new("", ""); overlay.delegated_token = Some(RawDelegatedToken::new( "x", @@ -608,9 +604,7 @@ mod tests { vec!["service:call".into()], Utc::now(), )); - p.delegation_mode = Some( - crate::extensions::raw_credentials::DelegationMode::AsGateway, - ); + p.delegation_mode = Some(crate::extensions::raw_credentials::DelegationMode::AsGateway); let updated = p.apply_to_extensions(Extensions::default()); let raw = updated.raw_credentials.as_ref().unwrap(); @@ -650,9 +644,8 @@ mod tests { 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::AsGateway, - ); + overlay.delegation_mode = + Some(crate::extensions::raw_credentials::DelegationMode::AsGateway); base.merge(overlay); assert!(matches!( base.delegation_mode, diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 333f725e..3d1f6934 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -515,7 +515,7 @@ impl Executor { // 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 { @@ -526,7 +526,7 @@ impl Executor { ); 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 @@ -537,10 +537,10 @@ impl Executor { phase_label, plugin_name, ); errors.push((&e).into()); - } + }, OnError::Ignore => { errors.push((&e).into()); - } + }, OnError::Disable => { warn!( "{} plugin '{}' disabled after error", @@ -548,9 +548,9 @@ impl Executor { ); errors.push((&e).into()); entry.plugin_ref.disable(); - } + }, } - } + }, Err(_) => { error!("{} plugin '{}' timed out", phase_label, plugin_name); let timeout_err = crate::error::PluginError::Timeout { @@ -566,17 +566,17 @@ impl Executor { ); 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", @@ -584,9 +584,9 @@ impl Executor { ); errors.push((&timeout_err).into()); entry.plugin_ref.disable(); - } + }, } - } + }, } // Commit this plugin's context back to the table — replaces the @@ -644,7 +644,7 @@ impl Executor { // 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(Ok(_)) => {}, // read-only — discard result and ext_clone Ok(Err(e)) => { warn!( "{} plugin '{}' error (ignored): {}", @@ -658,7 +658,7 @@ impl Executor { ); entry.plugin_ref.disable(); } - } + }, Err(_) => { warn!( "{} plugin '{}' timed out (ignored)", @@ -677,7 +677,7 @@ impl Executor { ); entry.plugin_ref.disable(); } - } + }, } } } @@ -772,7 +772,7 @@ impl Executor { v }); BranchData::Deny(violation) - } + }, // `Some(..)` with continue_processing=true, OR // `None` (downcast failed — historically logged // and treated as Allow) both fall through. @@ -817,7 +817,7 @@ impl Executor { let on_error = on_error_by_idx[idx]; match outcome { - BranchOutcome::Completed(BranchData::Allow) => {} + BranchOutcome::Completed(BranchData::Allow) => {}, BranchOutcome::Completed(BranchData::Deny(opt_v)) => { let violation = opt_v.unwrap_or_else(|| { let mut v = crate::error::PluginViolation::new( @@ -830,7 +830,7 @@ impl Executor { if first_violation.is_none() { first_violation = Some(violation); } - } + }, BranchOutcome::Completed(BranchData::Error(e)) => match on_error { OnError::Fail => { if first_violation.is_none() { @@ -841,16 +841,16 @@ impl Executor { 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 { @@ -868,18 +868,18 @@ impl Executor { 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 { @@ -900,23 +900,23 @@ impl Executor { 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. - } + }, } } @@ -982,19 +982,19 @@ impl Executor { timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await; match result { - Ok(Ok(_)) => {} // discard + 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 ); - } + }, } }); @@ -1044,7 +1044,7 @@ pub fn extract_erased(result: Box) -> Option { warn!("extract_erased: downcast failed — handler returned unexpected type"); None - } + }, } } diff --git a/crates/cpex-core/src/extensions/authorization.rs b/crates/cpex-core/src/extensions/authorization.rs index caffbb80..fe95c15a 100644 --- a/crates/cpex-core/src/extensions/authorization.rs +++ b/crates/cpex-core/src/extensions/authorization.rs @@ -75,7 +75,13 @@ mod tests { }"#; 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")); + 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/filter.rs b/crates/cpex-core/src/extensions/filter.rs index c8d1cdd0..8bed45a2 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -339,9 +339,11 @@ pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet 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.raw_credentials = Some(Arc::new(build_filtered_raw_credentials( + raw, + allow_inbound, + allow_delegated, + ))); } } @@ -731,7 +733,10 @@ mod tests { 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.client.is_none(), + "client must be hidden without read_client" + ); assert!( sec.caller_workload.is_none(), "caller_workload must be hidden without read_workload", diff --git a/crates/cpex-core/src/extensions/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs index f3d175b7..b479f589 100644 --- a/crates/cpex-core/src/extensions/raw_credentials.rs +++ b/crates/cpex-core/src/extensions/raw_credentials.rs @@ -259,7 +259,11 @@ mod tests { 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("eyJhbGciOiJSUzI1NiJ9"), + "raw token leaked into serialized form: {}", + json + ); assert!(json.contains("Authorization")); assert!(json.contains("jwt")); } @@ -283,7 +287,11 @@ mod tests { Utc::now(), ); let json = serde_json::to_string(&tok).unwrap(); - assert!(!json.contains("minted-secret-bytes"), "delegated token leaked: {}", json); + assert!( + !json.contains("minted-secret-bytes"), + "delegated token leaked: {}", + json + ); assert!(json.contains("downstream.example.com")); } diff --git a/crates/cpex-core/src/hooks/metadata.rs b/crates/cpex-core/src/hooks/metadata.rs index ff9de667..cc501740 100644 --- a/crates/cpex-core/src/hooks/metadata.rs +++ b/crates/cpex-core/src/hooks/metadata.rs @@ -74,10 +74,10 @@ 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, + 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; @@ -174,47 +174,77 @@ const BUILTIN_METADATA: &[(&str, HookMetadata)] = &[ // CMF tool ( HOOK_CMF_TOOL_PRE_INVOKE, - HookMetadata { entity_type: Some(ENTITY_TOOL), phase: HookPhase::Pre }, + HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }, ), ( HOOK_CMF_TOOL_POST_INVOKE, - HookMetadata { entity_type: Some(ENTITY_TOOL), phase: HookPhase::Post }, + HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Post, + }, ), // CMF llm ( HOOK_CMF_LLM_INPUT, - HookMetadata { entity_type: Some(ENTITY_LLM), phase: HookPhase::Pre }, + HookMetadata { + entity_type: Some(ENTITY_LLM), + phase: HookPhase::Pre, + }, ), ( HOOK_CMF_LLM_OUTPUT, - HookMetadata { entity_type: Some(ENTITY_LLM), phase: HookPhase::Post }, + 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 }, + HookMetadata { + entity_type: Some(ENTITY_PROMPT), + phase: HookPhase::Pre, + }, ), ( HOOK_CMF_PROMPT_POST_INVOKE, - HookMetadata { entity_type: Some(ENTITY_PROMPT), phase: HookPhase::Post }, + 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 }, + HookMetadata { + entity_type: Some(ENTITY_RESOURCE), + phase: HookPhase::Pre, + }, ), ( HOOK_CMF_RESOURCE_POST_FETCH, - HookMetadata { entity_type: Some(ENTITY_RESOURCE), phase: HookPhase::Post }, + 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 }, + HookMetadata { + entity_type: None, + phase: HookPhase::Unphased, + }, ), ( HOOK_TOKEN_DELEGATE, - HookMetadata { entity_type: None, phase: HookPhase::Unphased }, + HookMetadata { + entity_type: None, + phase: HookPhase::Unphased, + }, ), ]; diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index 4139b670..8ad5115e 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -25,7 +25,9 @@ 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 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/identity/payload.rs b/crates/cpex-core/src/identity/payload.rs index ed886d5e..42c95221 100644 --- a/crates/cpex-core/src/identity/payload.rs +++ b/crates/cpex-core/src/identity/payload.rs @@ -329,9 +329,8 @@ impl IdentityPayload { /// **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(); + 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 @@ -374,10 +373,7 @@ mod tests { #[test] fn raw_token_serializes_without_secret() { - let p = IdentityPayload::new( - "eyJhbGciOiJSUzI1NiJ9.payload.sig", - TokenSource::Bearer, - ); + let p = IdentityPayload::new("eyJhbGciOiJSUzI1NiJ9.payload.sig", TokenSource::Bearer); let json = serde_json::to_string(&p).unwrap(); assert!( !json.contains("eyJhbGciOiJSUzI1NiJ9"), @@ -416,7 +412,10 @@ mod tests { 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")); + assert_eq!( + p.headers().get("user-agent").map(String::as_str), + Some("curl/8.0") + ); } #[test] @@ -431,8 +430,11 @@ mod tests { 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")); + 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()); } @@ -456,5 +458,4 @@ mod tests { 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/manager.rs b/crates/cpex-core/src/manager.rs index 1dfb7b05..eaf1df1e 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -589,7 +589,10 @@ impl PluginManager { }; let mgr: Arc = Arc::clone(self); - let global_yaml = raw.get("global").cloned().unwrap_or(serde_yaml::Value::Null); + 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) @@ -619,7 +622,9 @@ impl PluginManager { if let Some(defaults) = &defaults_yaml { for (k, v) in defaults { - let Some(entity_type) = k.as_str() else { continue }; + 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!( @@ -1239,8 +1244,7 @@ impl PluginManager { hook_name: impl Into, handler: Arc, config: crate::plugin::PluginConfig, - ) - where + ) where H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static, { let key = AnnotationKey { @@ -1356,7 +1360,7 @@ impl PluginManager { .cloned() .collect(); return Arc::new(filtered); - } + }, }; // Extract entity info from meta extension @@ -1592,7 +1596,7 @@ impl PluginManager { "build_override_entries: YAML→JSON config conversion failed", ); return Vec::new(); - } + }, }; merged_config.config = Some(cfg_json); @@ -1608,7 +1612,7 @@ impl PluginManager { "build_override_entries: no factory registered for kind", ); return Vec::new(); - } + }, }; match factory.create(&merged_config) { Ok(i) => i, @@ -1619,7 +1623,7 @@ impl PluginManager { "build_override_entries: factory.create failed", ); return Vec::new(); - } + }, } }; @@ -1723,7 +1727,7 @@ impl PluginManager { base_config.name, e ); return None; // fall back to base instance - } + }, } }; @@ -1742,7 +1746,7 @@ impl PluginManager { base_config.name, target_hook ); return None; - } + }, }; // Initialize the new instance — without this, plugins that need to @@ -1805,9 +1809,7 @@ impl PluginManager { /// 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.registry.has_hooks_for(&HookType::new(hook_name)) || snapshot .route_annotations .keys() diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index 30f68bf3..f7da2c37 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -517,7 +517,7 @@ pub fn group_by_mode(entries: &[HookEntry]) -> GroupedHookEntries { PluginMode::Audit => audit.push(entry.clone()), PluginMode::Concurrent => concurrent.push(entry.clone()), PluginMode::FireAndForget => fire_and_forget.push(entry.clone()), - PluginMode::Disabled => {} // skip + PluginMode::Disabled => {}, // skip } } diff --git a/crates/cpex-core/tests/delegation_e2e.rs b/crates/cpex-core/tests/delegation_e2e.rs index 10ff13b9..4307b6f5 100644 --- a/crates/cpex-core/tests/delegation_e2e.rs +++ b/crates/cpex-core/tests/delegation_e2e.rs @@ -138,10 +138,9 @@ impl HookHandler for DecliningHandler { // 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"), - ); + updated + .metadata + .insert("declined_by".into(), serde_json::json!("declining-handler")); PluginResult::modify_payload(updated) } } @@ -271,15 +270,9 @@ fn extract_delegation(result: &cpex_core::executor::PipelineResult) -> Delegatio 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(); + 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 @@ -386,15 +379,9 @@ async fn declining_then_fallback_chain_mints_token() { 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(); + 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 @@ -423,15 +410,9 @@ async fn rejecting_handler_halts_pipeline() { 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(); + 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. @@ -678,8 +659,8 @@ async fn cap_gating_post_apply_through_cmf_dispatch() { ) .await; assert!(td_result.continue_processing); - let delegation = DelegationPayload::from_pipeline_result(&td_result) - .expect("delegation should have minted"); + let delegation = + DelegationPayload::from_pipeline_result(&td_result).expect("delegation should have minted"); // 3. Apply. let updated_ext = delegation.apply_to_extensions(initial_ext); @@ -689,12 +670,7 @@ async fn cap_gating_post_apply_through_cmf_dispatch() { message: Message::text(Role::User, "fetch compensation"), }; let (cmf_result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload, - updated_ext, - None, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload, updated_ext, None) .await; assert!( cmf_result.continue_processing, diff --git a/crates/cpex-core/tests/identity_e2e.rs b/crates/cpex-core/tests/identity_e2e.rs index d262a1b2..269e0906 100644 --- a/crates/cpex-core/tests/identity_e2e.rs +++ b/crates/cpex-core/tests/identity_e2e.rs @@ -179,10 +179,7 @@ fn config(name: &str, priority: i32) -> PluginConfig { /// 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("authorization".to_string(), format!("Bearer {}", token)); headers.insert( "x-spiffe-id".to_string(), "spiffe://example.com/agent-1".to_string(), @@ -214,7 +211,8 @@ async fn single_resolver_populates_subject() { cfg: cfg.clone(), subject_id: "alice@corp.com".to_string(), }); - mgr.register_handler::(plugin, cfg).unwrap(); + mgr.register_handler::(plugin, cfg) + .unwrap(); mgr.initialize().await.unwrap(); let (result, _bg) = mgr @@ -312,7 +310,8 @@ 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.register_handler::(plugin, cfg) + .unwrap(); mgr.initialize().await.unwrap(); let (result, _bg) = mgr @@ -342,10 +341,10 @@ async fn rejecting_resolver_halts_pipeline() { /// landed. #[tokio::test] async fn apply_to_extensions_populates_security_and_preserves_existing_fields() { - use cpex_core::extensions::SecurityExtension; use cpex_core::extensions::raw_credentials::{ RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, }; + use cpex_core::extensions::SecurityExtension; // ----- Handler: produces a subject + a RawCredentialsExtension ----- struct FullResolver { @@ -385,7 +384,8 @@ async fn apply_to_extensions_populates_security_and_preserves_existing_fields() 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.register_handler::(plugin, cfg) + .unwrap(); mgr.initialize().await.unwrap(); // ----- Host's initial Extensions carries a pre-existing label ----- @@ -416,7 +416,10 @@ async fn apply_to_extensions_populates_security_and_preserves_existing_fields() 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"); + 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"), @@ -452,7 +455,8 @@ 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.register_handler::(plugin, cfg) + .unwrap(); mgr.initialize().await.unwrap(); let (result, _bg) = mgr @@ -694,8 +698,8 @@ async fn cap_gating_post_apply_through_cmf_dispatch() { ) .await; assert!(id_result.continue_processing); - let identity = IdentityPayload::from_pipeline_result(&id_result) - .expect("identity should have resolved"); + let identity = + IdentityPayload::from_pipeline_result(&id_result).expect("identity should have resolved"); // 3. Apply. let updated_ext = identity.apply_to_extensions(initial_ext); @@ -706,12 +710,7 @@ async fn cap_gating_post_apply_through_cmf_dispatch() { message: Message::text(Role::User, "fetch sensitive data"), }; let (cmf_result, _bg) = mgr - .invoke_named::( - "cmf.tool_pre_invoke", - cmf_payload, - updated_ext, - None, - ) + .invoke_named::("cmf.tool_pre_invoke", cmf_payload, updated_ext, None) .await; assert!( cmf_result.continue_processing, diff --git a/crates/cpex-core/tests/identity_route_e2e.rs b/crates/cpex-core/tests/identity_route_e2e.rs index 05ae3b19..df26f892 100644 --- a/crates/cpex-core/tests/identity_route_e2e.rs +++ b/crates/cpex-core/tests/identity_route_e2e.rs @@ -90,19 +90,18 @@ impl HookHandler for RecordingResolver { // `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(), - }); + *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 { @@ -149,8 +148,9 @@ impl PluginFactory for RecordingFactory { .clone() .unwrap_or_else(|| Arc::new(Mutex::new(None))), }); - let adapter: Arc = - Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); Ok(PluginInstance { plugin: plugin as Arc, handlers: vec![(HOOK_IDENTITY_RESOLVE, adapter)], @@ -631,10 +631,7 @@ routes: // 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"], - ); + assert_eq!(ledger.lock().unwrap().clone(), vec!["legacy-basic-auth"],); } /// `replace_inherited: true` + `steps: []` — the explicit diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index fd5544a8..ead295a7 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -14,22 +14,28 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true +publish = false +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true [lib] crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -cpex-core = { path = "../cpex-core" } +cpex-core = { path = "../cpex-core", version = "0.2.0" } # 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 = { path = "../apl-cpex" } +apl-cpex = { path = "../apl-cpex", version = "0.2.0" } # 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 plus # the cedar-direct PDP (note: NOT `cel`, matching the prior footprint). -cpex-builtins = { path = "../cpex-builtins", default-features = false, features = [ +cpex-builtins = { path = "../cpex-builtins", version = "0.2.0", default-features = false, features = [ "pii-scanner", "audit-logger", "identity-jwt", @@ -52,3 +58,6 @@ valkey = ["cpex-builtins/valkey"] [dev-dependencies] async-trait = { workspace = true } + +[lints] +workspace = true diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs index cd7a43bf..b7a7d2c4 100644 --- a/crates/cpex-ffi/src/apl.rs +++ b/crates/cpex-ffi/src/apl.rs @@ -87,6 +87,6 @@ pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int Err(_panic) => { 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 15208453..c6d748d8 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -156,7 +156,7 @@ fn worker_threads_from_env() -> Option { raw, ); None - } + }, Err(_) => { tracing::warn!( "cpex-ffi: {}={:?} is not parseable as a positive integer; using num_cpus default", @@ -164,7 +164,7 @@ fn worker_threads_from_env() -> Option { raw, ); None - } + }, } } @@ -229,7 +229,7 @@ pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { Err(e) => { tracing::error!("cpex_configure_runtime: build failed: {}", e); return RC_PIPELINE_ERROR; - } + }, }; match SHARED_RUNTIME.set(rt) { Ok(()) => RC_OK, @@ -241,7 +241,7 @@ pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { configuration ignored. Call before any cpex_manager_new.", ); RC_INVALID_INPUT - } + }, } } @@ -303,7 +303,7 @@ where FFI_WALL_CLOCK_TIMEOUT.as_secs(), ); SafeRun::Timeout - } + }, Err(_panic_payload) => { tracing::error!( "FFI {}: plugin panicked across FFI boundary — caught to \ @@ -311,7 +311,7 @@ where op_name, ); SafeRun::Panicked - } + }, } } @@ -339,17 +339,17 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result { let msg: cpex_core::cmf::MessagePayload = rmp_serde::from_slice(bytes) .map_err(|e| format!("CMF payload deserialize failed: {}", e))?; Ok(Box::new(msg)) - } + }, PAYLOAD_IDENTITY => { let idp: IdentityPayload = rmp_serde::from_slice(bytes) .map_err(|e| format!("identity payload deserialize failed: {}", e))?; Ok(Box::new(idp)) - } + }, _ => Err(format!("unknown payload type: {}", payload_type)), } } @@ -494,7 +494,7 @@ pub unsafe extern "C" fn cpex_manager_new( 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 @@ -575,11 +575,11 @@ pub unsafe extern "C" fn cpex_load_config( Ok(Err(e)) => { tracing::error!("cpex_load_config: load_config failed: {}", e); RC_PIPELINE_ERROR - } + }, Err(_panic) => { tracing::error!("cpex_load_config: panic caught at FFI boundary"); RC_PANIC - } + }, } } @@ -602,7 +602,7 @@ pub unsafe extern "C" fn cpex_initialize(mgr: *const CpexManagerInner) -> c_int SafeRun::Ok(Err(e)) => { tracing::error!("cpex_initialize: {}", e); RC_PIPELINE_ERROR - } + }, other => other.rc(), // RC_TIMEOUT or RC_PANIC; already logged } } @@ -833,7 +833,7 @@ pub unsafe extern "C" fn cpex_invoke( Err(e) => { tracing::error!("cpex_invoke: {}", e); return RC_PARSE_ERROR; - } + }, }; // Deserialize extensions from MessagePack @@ -847,7 +847,7 @@ pub unsafe extern "C" fn cpex_invoke( Err(e) => { tracing::error!("cpex_invoke: extensions deserialize failed: {}", e); return RC_PARSE_ERROR; - } + }, } } else { Extensions::default() @@ -888,7 +888,7 @@ pub unsafe extern "C" fn cpex_invoke( proto_error_code: None, }); (payload_type, None) - } + }, }, }; @@ -915,7 +915,7 @@ pub unsafe extern "C" fn cpex_invoke( Err(e) => { tracing::error!("cpex_invoke: result serialize failed: {}", e); return RC_SERIALIZE_ERROR; - } + }, }; // Return result bytes @@ -1007,7 +1007,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( Err(e) => { tracing::error!("cpex_invoke_resolved: {}", e); return RC_PARSE_ERROR; - } + }, }; let base_extensions: Extensions = if extensions_len > 0 { @@ -1020,7 +1020,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( Err(e) => { tracing::error!("cpex_invoke_resolved: extensions deserialize failed: {}", e); return RC_PARSE_ERROR; - } + }, } } else { Extensions::default() @@ -1048,7 +1048,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( e ); return RC_PARSE_ERROR; - } + }, }; let (id_result, _id_bg) = match run_safely( @@ -1142,7 +1142,7 @@ unsafe fn finish_pipeline_result( proto_error_code: None, }); (payload_type, None) - } + }, }, }; @@ -1166,7 +1166,7 @@ unsafe fn finish_pipeline_result( Err(e) => { tracing::error!("cpex_invoke_resolved: result serialize failed: {}", e); return RC_SERIALIZE_ERROR; - } + }, }; let (ptr, len) = alloc_bytes(&result_bytes); @@ -1216,7 +1216,7 @@ pub unsafe extern "C" fn cpex_wait_background( drop(Box::from_raw(bg_handle)); } return RC_INVALID_HANDLE; - } + }, }; if bg_handle.is_null() { diff --git a/crates/cpex-orchestration/Cargo.toml b/crates/cpex-orchestration/Cargo.toml index 1f221e2d..cd137193 100644 --- a/crates/cpex-orchestration/Cargo.toml +++ b/crates/cpex-orchestration/Cargo.toml @@ -23,6 +23,11 @@ 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] @@ -32,3 +37,6 @@ 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 index ff884fb4..c4649177 100644 --- a/crates/cpex-orchestration/src/lib.rs +++ b/crates/cpex-orchestration/src/lib.rs @@ -192,7 +192,7 @@ where // (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 @@ -206,7 +206,7 @@ where slots[idx] = Some(BranchOutcome::Panicked(payload)); } } - } + }, } } @@ -245,8 +245,8 @@ pub type ErasedBranch = BoxFuture<'static, T>; #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; fn no_deny(_: &T) -> bool { false @@ -269,7 +269,10 @@ mod tests { let out = run_branches( branches, - BranchConfig { timeout_per_branch: None, short_circuit_on_deny: false }, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, no_deny::, ) .await; @@ -399,10 +402,8 @@ mod tests { #[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 branches: Vec> = + vec![Box::pin(async { panic!("boom") }), Box::pin(async { 42 })]; let out = run_branches( branches, BranchConfig { @@ -413,7 +414,9 @@ mod tests { ) .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::Completed(42)))); assert!(out.iter().any(|o| matches!(o, BranchOutcome::Panicked(_)))); } diff --git a/crates/cpex-sdk/Cargo.toml b/crates/cpex-sdk/Cargo.toml index 1077a33f..780043ef 100644 --- a/crates/cpex-sdk/Cargo.toml +++ b/crates/cpex-sdk/Cargo.toml @@ -14,9 +14,17 @@ 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 = { path = "../cpex-core" } +cpex-core = { path = "../cpex-core", version = "0.2.0" } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/cpex/Cargo.toml b/crates/cpex/Cargo.toml index e41baea3..137e16a8 100644 --- a/crates/cpex/Cargo.toml +++ b/crates/cpex/Cargo.toml @@ -24,6 +24,11 @@ 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] # Engine only by default: no builtin plugins are compiled in. Opt into the @@ -47,12 +52,15 @@ valkey = ["cpex-builtins/valkey"] [dependencies] # Host runtime — always present, this is the point of the facade. -cpex-core = { path = "../cpex-core" } -apl-core = { path = "../apl-core" } -apl-cmf = { path = "../apl-cmf" } -apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core", version = "0.2.0" } +apl-core = { path = "../apl-core", version = "0.2.0" } +apl-cmf = { path = "../apl-cmf", version = "0.2.0" } +apl-cpex = { path = "../apl-cpex", version = "0.2.0" } # 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 = { path = "../cpex-builtins", optional = true, default-features = false } +cpex-builtins = { path = "../cpex-builtins", version = "0.2.0", optional = true, default-features = false } + +[lints] +workspace = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..20b7b8c9 --- /dev/null +++ b/deny.toml @@ -0,0 +1,62 @@ +# cargo-deny configuration — supply-chain gate run by `make audit` and the +# supply-chain CI workflow. Checks advisories, licenses, dependency bans, and +# crate sources. + +[advisories] +unmaintained = "all" +maximum-db-staleness = "P30D" +# 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 safe upgrade available. Test-only + # (fixture keygen); the crate validates JWTs, it does not generate RSA keys. + "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", +] + +[licenses] +# SPDX expressions we accept. Extend deliberately — a new entry means a new +# license obligation we are taking on. +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "Unlicense", + "Zlib", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" +allow-wildcard-paths = true +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# No git dependencies: every crate resolves from crates.io. +allow-git = [] diff --git a/docs/content/_index.md b/docs/content/_index.md index 4b9aa00d..3fd19136 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -1,53 +1,39 @@ --- -title: "CPEX — ContextForge Plugin Extensibility Framework" +title: "CPEX" type: docs +weight: 0 --- # CPEX -**A composable enforcement framework for AI agents and toolchains** +**A policy and authorization framework for agentic applications.** -CPEX lets you intercept, enforce, and extend application behavior through plugins — without modifying core logic. Define hook points in your application, write plugins that attach to them, and compose enforcement pipelines that run automatically. +CPEX is a deterministic reference monitor between an untrusted agent and the capabilities it invokes. AI agents can be steered by injected content, confused by tool output, or simply make mistakes. CPEX mediates every operation an agent triggers (tool calls, A2A methods, inference calls, prompt and resource fetches) against state the agent cannot see or forge: identity, delegation chains, taint labels, and an append-only audit log. -```python -from cpex.framework import hook, Plugin, PluginResult, PluginViolation +You write policy in APL (Authorization Policy Language): a declarative, attribute-based rules with explicit effects. CPEX evaluates that policy at the boundary and enforces the result, allowing, denying, redacting, delegating, or tainting before the operation proceeds. -class RateLimitPlugin(Plugin): - @hook("tool_pre_invoke") - async def check_rate_limit(self, payload, context): - if self.is_over_limit(context): - return PluginResult( - continue_processing=False, - violation=PluginViolation(reason="Rate limit exceeded", code="RATE_LIMIT") - ) - return PluginResult(continue_processing=True) -``` +![CPEX mediates every operation an untrusted LLM triggers, evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge](/cpex/images/cpex_overview.png) -Register the plugin, and it runs at every hook invocation. No changes to your application logic. - -### What you can build with CPEX - -- **Security** — access control, prompt injection detection, data loss prevention -- **Observability** — request tracing, audit logging, metrics collection -- **Governance** — policy enforcement, compliance validation, approval workflows -- **Reliability** — rate limiting, circuit breakers, response validation +The plugin pipeline underneath (hooks, the plugin manager, execution modes) is the mechanism that runs policy effects. It is the supporting layer, not the headline. APL is how you express intent; the pipeline is how that intent executes. --- {{% columns %}} +- ### Get started -- ### Get Started - Install CPEX and build your first plugin in five minutes. + Stand up CPEX as an enforcement point and run your first policy. [Quick Start →]({{< relref "/docs/quickstart" >}}) -- ### Learn the Concepts - Understand hooks, execution modes, and the plugin pipeline. +- ### Write policy + + Learn APL: predicates, effects, sequencing, PDPs, delegation, and tainting. + + [APL →]({{< relref "/docs/apl" >}}) - [Overview →]({{< relref "/docs/overview" >}}) +- ### Why CPEX -- ### Project Vision - Why hooks, plugins, and policy are the path to agent security. + The reference-monitor model and where CPEX sits in an agent stack. [Vision →]({{< relref "/docs/vision" >}}) diff --git a/docs/content/docs/0.1.x/_index.md b/docs/content/docs/0.1.x/_index.md new file mode 100644 index 00000000..ad6de2be --- /dev/null +++ b/docs/content/docs/0.1.x/_index.md @@ -0,0 +1,15 @@ +--- +title: "0.1.x (Legacy)" +weight: 900 +bookCollapseSection: true +cascade: + bookSearchExclude: true +--- + +# CPEX 0.1.x (Legacy) + +These pages document **CPEX 0.1.x**, the pure-Python plugin framework. They are preserved for users still on that line. + +CPEX `0.2`+ is a Rust framework with a different architecture and a policy-first design centered on APL (Authorization Policy Language). The reposition is a re-architecture, not an abandonment: 0.1.x remains available on the [`0.1.x` branch](https://github.com/contextforge-org/cpex/tree/0.1.x). + +If you are starting fresh or evaluating CPEX, read the [current documentation]({{< relref "/docs" >}}) instead. The concepts, APIs, and configuration below do not apply to `0.2`+. diff --git a/docs/content/docs/api-reference.md b/docs/content/docs/0.1.x/api-reference.md similarity index 99% rename from docs/content/docs/api-reference.md rename to docs/content/docs/0.1.x/api-reference.md index 5546e64f..596f5b7b 100644 --- a/docs/content/docs/api-reference.md +++ b/docs/content/docs/0.1.x/api-reference.md @@ -1,6 +1,8 @@ --- title: "API Reference" weight: 140 +aliases: + - /docs/api-reference/ --- # API Reference diff --git a/docs/content/docs/cli.md b/docs/content/docs/0.1.x/cli.md similarity index 99% rename from docs/content/docs/cli.md rename to docs/content/docs/0.1.x/cli.md index cb089f31..2b7f84a7 100644 --- a/docs/content/docs/cli.md +++ b/docs/content/docs/0.1.x/cli.md @@ -1,6 +1,8 @@ --- title: "CLI Tools" weight: 130 +aliases: + - /docs/cli/ --- # CLI Tools diff --git a/docs/content/docs/0.1.x/cmf.md b/docs/content/docs/0.1.x/cmf.md new file mode 100644 index 00000000..a55211a3 --- /dev/null +++ b/docs/content/docs/0.1.x/cmf.md @@ -0,0 +1,167 @@ +--- +title: "Common Message Format" +weight: 50 +--- + +# Common Message Format (CMF) + +The Common Message Format is a canonical message representation for interactions between users, agents, tools, and language models. It lets you write a single plugin that evaluates content at *every* interception point — tool calls, LLM input/output, resource access — using one unified interface. + +--- + +## Why CMF? + +Without CMF, you write separate handlers for each hook type: + +```python +async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context): + # check tool arguments for prohibited content + ... + +async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context): + # check agent messages for prohibited content — same logic, different payload + ... +``` + +With CMF, you write the logic once and register for multiple hook points: + +```python +from cpex.framework import hook, Plugin, PluginContext +from cpex.framework.hooks.message import CmfHookType, MessagePayload, MessageResult + + +class ContentGuardrailPlugin(Plugin): + @hook([CmfHookType.TOOL_PRE_INVOKE, CmfHookType.LLM_INPUT, CmfHookType.LLM_OUTPUT]) + async def evaluate(self, payload: MessagePayload, context: PluginContext) -> MessageResult: + for view in payload.message.iter_views(): + if view.text and self._contains_prohibited_content(view.text): + return MessageResult( + continue_processing=False, + violation=PluginViolation( + reason="Prohibited content detected", + description=f"Content blocked at {payload.hook.value}", + code="CONTENT_BLOCKED", + ), + ) + return MessageResult(continue_processing=True) +``` + +--- + +## Message + +A `Message` is the top-level CMF object representing a single turn in a conversation: + +```python +from cpex.framework.cmf.message import Message, Role, TextContent, ToolCallContentPart, ToolCall + +msg = Message( + role=Role.ASSISTANT, + content=[ + TextContent(text="Let me look that up."), + ToolCallContentPart( + content=ToolCall( + tool_call_id="tc_001", + name="web_search", + arguments={"query": "CPEX framework"}, + ), + ), + ], +) + +msg.role # Role.ASSISTANT +msg.content[0].text # "Let me look that up." +msg.content[1].content.name # "web_search" +``` + +Messages are frozen. Use `model_copy(update={...})` to create modified copies. + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `schema_version` | `str` | Schema version (default `"2.0"`) | +| `role` | `Role` | Who is speaking: `SYSTEM`, `DEVELOPER`, `USER`, `ASSISTANT`, `TOOL` | +| `content` | `list[ContentPartUnion]` | List of typed content parts (multimodal) | +| `channel` | `Channel \| None` | Output classification: `ANALYSIS`, `COMMENTARY`, `FINAL` | + +--- + +## Content Parts + +Messages carry a list of typed content parts. Each part has a `content_type` discriminator: + +| Content Type | Class | Wraps | +|-------------|-------|-------| +| `text` | `TextContent` | Plain text | +| `thinking` | `ThinkingContent` | Chain-of-thought reasoning | +| `tool_call` | `ToolCallContentPart` | `ToolCall` — function invocation request | +| `tool_result` | `ToolResultContentPart` | `ToolResult` — function execution result | +| `resource` | `ResourceContentPart` | `Resource` — embedded resource with content | +| `resource_ref` | `ResourceRefContentPart` | `ResourceReference` — lightweight reference | +| `prompt_request` | `PromptRequestContentPart` | `PromptRequest` — template invocation | +| `prompt_result` | `PromptResultContentPart` | `PromptResult` — rendered template | +| `image` | `ImageContentPart` | `ImageSource` — URL or base64 image | +| `video` | `VideoContentPart` | `VideoSource` — URL or base64 video | +| `audio` | `AudioContentPart` | `AudioSource` — URL or base64 audio | +| `document` | `DocumentContentPart` | `DocumentSource` — PDF, Word, etc. | + +--- + +## MessageView + +`Message.iter_views()` decomposes a message into individually addressable `MessageView` objects. Each view provides a uniform interface for policy evaluation regardless of content type: + +```python +for view in message.iter_views(): + print(f"kind={view.kind}, name={view.name}, text={view.text}") +``` + +This is the recommended way to inspect message content in plugins. Each view exposes the same fields, so your policy logic doesn't need to branch on content type. + +--- + +## CMF Hook Types + +CMF hooks parallel the typed hooks but accept `MessagePayload` instead of per-type payloads: + +| CMF Hook | Fires at | Parallel to | +|----------|----------|-------------| +| `cmf.tool_pre_invoke` | Before tool execution | `tool_pre_invoke` | +| `cmf.tool_post_invoke` | After tool execution | `tool_post_invoke` | +| `cmf.llm_input` | Before model/LLM call | — | +| `cmf.llm_output` | After model/LLM call | — | +| `cmf.prompt_pre_fetch` | Before prompt fetch | `prompt_pre_fetch` | +| `cmf.prompt_post_fetch` | After prompt fetch | `prompt_post_fetch` | +| `cmf.resource_pre_fetch` | Before resource fetch | `resource_pre_fetch` | +| `cmf.resource_post_fetch` | After resource fetch | `resource_post_fetch` | + +The gateway fires both the typed hook and the CMF hook at each interception point. You can use either or both. + +--- + +## MessagePayload + +The payload for all CMF hooks: + +```python +from cpex.framework.hooks.message import MessagePayload, MessageHookType + +payload = MessagePayload(message=msg, hook=MessageHookType.LLM_INPUT) +payload.message # the CMF Message +payload.hook # where in the pipeline this evaluation is happening +``` + +The `hook` field tells your plugin *where* the evaluation is happening, so you can apply different policies at different stages if needed. + +--- + +## Migration Path + +You can migrate from typed hooks to CMF incrementally: + +1. **Typed plugins** register for `tool_pre_invoke` and receive `ToolPreInvokePayload` +2. **CMF plugins** register for `cmf.tool_pre_invoke` and receive `MessagePayload` +3. Both fire at the same interception point — no conflict + +Start with CMF for new cross-cutting policies (content guardrails, PII scanning) where the unified interface saves duplication. Keep typed hooks for domain-specific logic that benefits from the typed payload fields. diff --git a/docs/content/docs/0.1.x/configuration.md b/docs/content/docs/0.1.x/configuration.md new file mode 100644 index 00000000..e5d2a386 --- /dev/null +++ b/docs/content/docs/0.1.x/configuration.md @@ -0,0 +1,222 @@ +--- +title: "Configuration" +weight: 90 +--- + +# Configuration Reference + +Plugins are configured in a YAML file. You pass the file path when creating the `PluginManager`: + +```python +manager = PluginManager("plugins/config.yaml") +``` + +Or set it via environment variable: + +```bash +export PLUGINS_CONFIG_FILE=plugins/config.yaml +export PLUGINS_ENABLED=true +``` + +--- + +## YAML Structure + +```yaml +plugin_dirs: + - ./plugins + +plugins: + - name: content_filter + kind: my_app.plugins.ContentFilterPlugin + version: "1.0.0" + description: "Blocks prohibited content in tool arguments" + author: "platform-team" + hooks: + - tool_pre_invoke + tags: + - security + - content + mode: sequential + on_error: fail + priority: 10 + conditions: + - server_ids: [prod-gateway] + tenant_ids: [tenant-a, tenant-b] + config: + blocked_patterns: + - "DROP TABLE" + - "rm -rf" +``` + +--- + +## Plugin Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | `str` | *required* | Unique plugin identifier | +| `kind` | `str` | *required* | Fully qualified class path (e.g., `my_app.plugins.MyPlugin`), `"external"` for remote plugins, or `"isolated_venv"` for venv-isolated plugins | +| `version` | `str` | — | Semantic version | +| `description` | `str` | — | Human-readable description | +| `author` | `str` | — | Plugin author | +| `hooks` | `list[str]` | `[]` | Hook types this plugin handles | +| `tags` | `list[str]` | `[]` | Searchable tags | +| `mode` | `str` | `sequential` | Execution mode — see [Execution Modes]({{< relref "/docs/0.1.x/execution-modes" >}}) | +| `on_error` | `str` | `fail` | Error behavior: `fail`, `ignore`, `disable` | +| `priority` | `int` | `100` | Execution order within mode (lower = higher priority) | +| `conditions` | `list` | `[]` | When the plugin should execute | +| `capabilities` | `list[str]` | `[]` | Declared capabilities for [extension access]({{< relref "/docs/0.1.x/extensions" >}}) | +| `config` | `dict` | — | Plugin-specific settings passed to the constructor | +| `max_content_size` | `int` | `10000000` | Maximum payload size in bytes | +| `mcp` | `object` | — | MCP client config (for [external plugins]({{< relref "/docs/0.1.x/external-plugins" >}})) | +| `grpc` | `object` | — | gRPC client config (for external plugins) | +| `unix_socket` | `object` | — | Unix socket client config (for external plugins) | + +--- + +## Plugin Directories + +`plugin_dirs` lists directories that CPEX adds to the Python path for plugin discovery. Use this when your plugin classes live outside the main application package: + +```yaml +plugin_dirs: + - ./plugins + - ./vendor/plugins +``` + +--- + +## Conditions + +Conditions restrict when a plugin executes. If conditions are set and none match, the plugin is skipped for that invocation. + +```yaml +conditions: + - server_ids: [prod-gateway, staging-gateway] + tenant_ids: [tenant-a] + - tools: [web_search, code_exec] +``` + +Available condition fields: + +| Field | Type | Description | +|-------|------|-------------| +| `server_ids` | `set[str]` | Match specific server IDs | +| `tenant_ids` | `set[str]` | Match specific tenant IDs | +| `tools` | `set[str]` | Match specific tool names | +| `prompts` | `set[str]` | Match specific prompt names | +| `resources` | `set[str]` | Match specific resource URIs | +| `agents` | `set[str]` | Match specific agent IDs | +| `user_patterns` | `list[str]` | Match user patterns | +| `content_types` | `list[str]` | Match content types | + +Multiple conditions are OR'd — the plugin runs if **any** condition matches. Fields within a single condition are AND'd. + +--- + +## Plugin-Specific Config + +The `config` dict is passed to your plugin's constructor via `PluginConfig.config`. You access it in `__init__`: + +```python +from pydantic import BaseModel +from cpex.framework import Plugin, PluginConfig + + +class FilterConfig(BaseModel): + blocked_patterns: list[str] + case_sensitive: bool = False + + +class ContentFilterPlugin(Plugin): + def __init__(self, config: PluginConfig): + super().__init__(config) + self._filter = FilterConfig.model_validate(config.config) +``` + +Validating with a Pydantic model gives you type safety and clear error messages if the YAML config is malformed. + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PLUGINS_ENABLED` | `false` | Enable the plugin framework | +| `PLUGINS_CONFIG_FILE` | `plugins/config.yaml` | Path to plugin configuration | +| `PLUGINS_PLUGIN_TIMEOUT` | `30` | Max execution time per plugin (seconds) | +| `PLUGINS_EXECUTION_POOL` | — | Max concurrent tasks (semaphore limit) | +| `PLUGINS_DEFAULT_HOOK_POLICY` | `allow` | Default policy for hooks without explicit rules: `allow` or `deny` | + +--- + +## Legacy Mode Migration + +If you are upgrading from an older version of CPEX, these mode names are automatically migrated: + +| Legacy Mode | Current Equivalent | +|-------------|-------------------| +| `enforce` | `sequential` | +| `permissive` | `transform` | +| `enforce_ignore_error` | `sequential` + `on_error: ignore` | + +--- + +## Complete Example + +```yaml +plugin_dirs: + - ./plugins + +plugins: + # Policy enforcement — blocks dangerous tools + - name: tool_policy + kind: plugins.security.ToolPolicyPlugin + version: "1.0.0" + hooks: + - tool_pre_invoke + mode: sequential + priority: 10 + on_error: fail + conditions: + - server_ids: [prod-gateway] + config: + blocked_tools: + - admin_delete + - raw_sql_exec + + # PII redaction — cleans arguments before tools run + - name: pii_redactor + kind: plugins.privacy.PIIRedactionPlugin + version: "1.0.0" + hooks: + - tool_pre_invoke + - tool_post_invoke + mode: transform + priority: 20 + on_error: ignore + + # Audit logging — async, never blocks + - name: audit_logger + kind: plugins.observability.AuditLogPlugin + version: "1.0.0" + hooks: + - tool_pre_invoke + - tool_post_invoke + - prompt_pre_fetch + mode: fire_and_forget + priority: 100 + on_error: ignore + + # Experimental policy — dry-run only + - name: new_content_policy + kind: plugins.experimental.ContentPolicyV2 + version: "0.1.0" + hooks: + - tool_pre_invoke + mode: audit + priority: 15 +``` + +This pipeline runs in order: `tool_policy` (sequential, blocks) → `pii_redactor` (transform, modifies) → `new_content_policy` (audit, observes) → `audit_logger` (fire_and_forget, logs in background). diff --git a/docs/content/docs/execution-modes.md b/docs/content/docs/0.1.x/execution-modes.md similarity index 98% rename from docs/content/docs/execution-modes.md rename to docs/content/docs/0.1.x/execution-modes.md index 483680a8..11022d75 100644 --- a/docs/content/docs/execution-modes.md +++ b/docs/content/docs/0.1.x/execution-modes.md @@ -1,11 +1,13 @@ --- title: "Execution Modes" weight: 45 +aliases: + - /docs/execution-modes/ --- # Execution Modes -Every plugin has an execution **mode** that controls whether it can block the pipeline, modify payloads, and how it runs relative to other plugins. Modes are set in the plugin's [YAML configuration]({{< relref "/docs/configuration" >}}). +Every plugin has an execution **mode** that controls whether it can block the pipeline, modify payloads, and how it runs relative to other plugins. Modes are set in the plugin's [YAML configuration]({{< relref "/docs/0.1.x/configuration" >}}). ## Phase Order diff --git a/docs/content/docs/0.1.x/extensions.md b/docs/content/docs/0.1.x/extensions.md new file mode 100644 index 00000000..15f4e627 --- /dev/null +++ b/docs/content/docs/0.1.x/extensions.md @@ -0,0 +1,199 @@ +--- +title: "Extensions & Capabilities" +weight: 60 +--- + +# Extensions & Capabilities + +Extensions carry typed contextual metadata — identity, security labels, HTTP headers, delegation chains — through the plugin pipeline. The capability system controls which plugins can see and modify which extension slots. + +--- + +## The Extensions Container + +`Extensions` is a frozen Pydantic model that attaches to payloads flowing through the pipeline. Each field is an optional typed slot: + +```python +from cpex.framework.extensions.extensions import Extensions +from cpex.framework.extensions.request import RequestExtension +from cpex.framework.extensions.security import SecurityExtension + +ext = Extensions( + request=RequestExtension(environment="production", request_id="req-001"), + security=SecurityExtension(labels=frozenset({"pii", "confidential"})), +) + +ext.request.environment # "production" +ext.security.labels # frozenset({"pii", "confidential"}) +ext.http # None — not populated +``` + +Extensions are frozen. To modify, use `model_copy(update={...})`: + +```python +updated = ext.model_copy(update={"custom": {"trace_id": "abc-123"}}) +``` + +--- + +## Extension Slots + +| Slot | Type | Description | Access | +|------|------|-------------|--------| +| `request` | `RequestExtension` | Environment, request ID, timestamp, tracing | Unrestricted | +| `agent` | `AgentExtension` | Session tracking, multi-agent lineage | `read_agent` | +| `http` | `HttpExtension` | HTTP headers | `read_headers` / `write_headers` | +| `security` | `SecurityExtension` | Labels, classification, subject identity | Mixed (see below) | +| `delegation` | `DelegationExtension` | Token delegation chain | `read_delegation` / `append_delegation` | +| `mcp` | `MCPExtension` | Tool, resource, or prompt metadata | Unrestricted | +| `completion` | `CompletionExtension` | Stop reason, token usage, model, latency | Unrestricted | +| `provenance` | `ProvenanceExtension` | Source, message ID, parent ID | Unrestricted | +| `llm` | `LLMExtension` | Model identity and capabilities | Unrestricted | +| `framework` | `FrameworkExtension` | Agentic framework context | Unrestricted | +| `meta` | `MetaExtension` | Host-provided operational metadata | Unrestricted | +| `custom` | `dict[str, Any]` | Free-form plugin data | Unrestricted | + +**Unrestricted** slots are visible to all plugins. **Capability-gated** slots require a declared capability. + +--- + +## Mutability Tiers + +Each extension slot has a mutability tier that the pipeline enforces: + +| Tier | Rule | Example | +|------|------|---------| +| **Immutable** | Set once, never changed. Pipeline rejects any delta. | `request`, `provenance`, `agent` | +| **Monotonic** | Can only grow — elements can be added, never removed. Pipeline validates `before ⊆ after`. | `security.labels`, `delegation.chain` | +| **Mutable** | Freely modifiable via copy-on-write. | `custom` | + +--- + +## Capabilities + +Capabilities are declared in the plugin's YAML config and control what a plugin can access: + +```yaml +plugins: + - name: header_injector + kind: my_app.HeaderInjectorPlugin + hooks: + - tool_pre_invoke + mode: sequential + capabilities: + - read_headers + - write_headers +``` + +Available capabilities: + +| Capability | Grants | +|-----------|--------| +| `read_subject` | Read subject ID and type | +| `read_roles` | Read subject roles (implies `read_subject`) | +| `read_teams` | Read subject teams (implies `read_subject`) | +| `read_claims` | Read subject claims (implies `read_subject`) | +| `read_permissions` | Read subject permissions (implies `read_subject`) | +| `read_agent` | Read agent extension | +| `read_headers` | Read HTTP headers | +| `write_headers` | Read + write HTTP headers | +| `read_labels` | Read security labels | +| `append_labels` | Read + append security labels (monotonic) | +| `read_delegation` | Read delegation chain | +| `append_delegation` | Read + append delegation chain (monotonic) | + +Write capabilities imply their corresponding read capability. A plugin with `write_headers` can also read headers. + +--- + +## How It Works + +The framework applies two filters around every plugin execution: + +1. **Before** — `filter_extensions()` builds a new `Extensions` containing only the slots the plugin has access to. Slots the plugin can't see are `None`. +2. **After** — `merge_extensions()` accepts back only the changes the plugin was authorized to make. Immutable slots are ignored. Monotonic slots are validated for growth. Unauthorized writes are silently discarded. + +This means plugins can't even *see* data they lack capabilities for, and they can't sneak in unauthorized changes. + +--- + +## Accepting Extensions in a Hook + +Add a third parameter to your hook signature: + +```python +from cpex.framework import hook, Plugin, PluginContext, PluginResult, ToolPreInvokePayload, ToolPreInvokeResult +from cpex.framework.extensions.extensions import Extensions + + +class HeaderInspectorPlugin(Plugin): + @hook("tool_pre_invoke") + async def inspect_headers( + self, + payload: ToolPreInvokePayload, + context: PluginContext, + extensions: Extensions, + ) -> ToolPreInvokeResult: + if extensions.http: + auth = extensions.http.headers.get("authorization", "none") + context.set_state("auth_method", auth.split()[0] if " " in auth else auth) + return ToolPreInvokeResult(continue_processing=True) +``` + +The framework detects the 3-parameter signature automatically and passes the capability-filtered extensions. + +--- + +## Returning Modified Extensions + +To modify extensions, return `modified_extensions` in the result: + +```python +from cpex.framework.extensions.extensions import Extensions +from cpex.framework.extensions.http import HttpExtension + + +class TokenDelegationPlugin(Plugin): + @hook("tool_pre_invoke") + async def delegate_token( + self, + payload: ToolPreInvokePayload, + context: PluginContext, + extensions: Extensions, + ) -> ToolPreInvokeResult: + delegated_token = await self._exchange_token(extensions) + + updated_http = HttpExtension( + headers={**(extensions.http.headers if extensions.http else {}), + "authorization": f"Bearer {delegated_token}"}, + ) + updated_ext = extensions.model_copy(update={"http": updated_http}) + + return ToolPreInvokeResult( + continue_processing=True, + modified_extensions=updated_ext, + ) +``` + +The manager merges only the fields the plugin is authorized to write. In this case, the plugin needs `write_headers` in its capabilities. + +--- + +## Security Sub-Field Gating + +The `security` extension has granular sub-field access control. A plugin with `read_roles` can see `security.subject.roles` but not `security.subject.claims`: + +```yaml +capabilities: + - read_roles + - read_labels +``` + +This plugin sees: +- `security.subject.id` and `security.subject.type` (implied by `read_roles`) +- `security.subject.roles` (granted by `read_roles`) +- `security.labels` (granted by `read_labels`) +- `security.objects`, `security.data`, `security.classification` (always unrestricted) + +It does **not** see: +- `security.subject.teams`, `security.subject.claims`, `security.subject.permissions` diff --git a/docs/content/docs/external-plugins.md b/docs/content/docs/0.1.x/external-plugins.md similarity index 99% rename from docs/content/docs/external-plugins.md rename to docs/content/docs/0.1.x/external-plugins.md index 0fced73f..cfbe283f 100644 --- a/docs/content/docs/external-plugins.md +++ b/docs/content/docs/0.1.x/external-plugins.md @@ -1,6 +1,8 @@ --- title: "External Plugins" weight: 70 +aliases: + - /docs/external-plugins/ --- # External Plugins diff --git a/docs/content/docs/hook-types.md b/docs/content/docs/0.1.x/hook-types.md similarity index 96% rename from docs/content/docs/hook-types.md rename to docs/content/docs/0.1.x/hook-types.md index c8ff8842..f19ba3a7 100644 --- a/docs/content/docs/hook-types.md +++ b/docs/content/docs/0.1.x/hook-types.md @@ -1,11 +1,13 @@ --- title: "Hook Types Reference" weight: 40 +aliases: + - /docs/hook-types/ --- # Hook Types Reference -CPEX ships with built-in hooks for common AI and application operations. Each hook defines a typed payload (the data your plugin receives) and a result type (what you return). You can also [register custom hooks]({{< relref "/docs/hooks#custom-hooks" >}}). +CPEX ships with built-in hooks for common AI and application operations. Each hook defines a typed payload (the data your plugin receives) and a result type (what you return). You can also [register custom hooks]({{< relref "/docs/0.1.x/hooks#custom-hooks" >}}). --- @@ -255,7 +257,7 @@ The result carries a `DelegationResult` as `modified_payload`, containing the de ## CMF Message Hooks -Unified hooks that use the [Common Message Format]({{< relref "/docs/cmf" >}}) for cross-cutting policy evaluation. These parallel the typed hooks above but accept a single `MessagePayload` wrapping a CMF `Message`. +Unified hooks that use the [Common Message Format]({{< relref "/docs/0.1.x/cmf" >}}) for cross-cutting policy evaluation. These parallel the typed hooks above but accept a single `MessagePayload` wrapping a CMF `Message`. | Hook | Fires at | |------|----------| @@ -270,7 +272,7 @@ Unified hooks that use the [Common Message Format]({{< relref "/docs/cmf" >}}) f **Payload:** `MessagePayload(message: Message, hook: MessageHookType)` | **Result:** `MessageResult` -CMF hooks let you write a single plugin that evaluates content at every interception point using one unified interface. See [Common Message Format]({{< relref "/docs/cmf" >}}) for details. +CMF hooks let you write a single plugin that evaluates content at every interception point using one unified interface. See [Common Message Format]({{< relref "/docs/0.1.x/cmf" >}}) for details. --- diff --git a/docs/content/docs/hooks.md b/docs/content/docs/0.1.x/hooks.md similarity index 94% rename from docs/content/docs/hooks.md rename to docs/content/docs/0.1.x/hooks.md index 0ea47daa..39ffcf0b 100644 --- a/docs/content/docs/hooks.md +++ b/docs/content/docs/0.1.x/hooks.md @@ -1,6 +1,8 @@ --- title: "Hooks" weight: 30 +aliases: + - /docs/hooks/ --- # Hooks @@ -31,7 +33,7 @@ class MyPlugin(Plugin): The framework validates the signature at registration time. It checks that the method: 1. Is `async` (not a regular function) -2. Accepts exactly 2 parameters (`payload`, `context`) — or 3 if the plugin uses [Extensions]({{< relref "/docs/extensions" >}}) +2. Accepts exactly 2 parameters (`payload`, `context`) — or 3 if the plugin uses [Extensions]({{< relref "/docs/0.1.x/extensions" >}}) --- @@ -118,7 +120,7 @@ async def tool_pre_invoke(self, payload, context): When a plugin blocks, the manager skips remaining plugins (in the current phase), fires any `fire_and_forget` tasks, and returns the violation to the caller. -Whether a plugin *can* block depends on its [execution mode]({{< relref "/docs/execution-modes" >}}). `sequential` and `concurrent` plugins can block; `transform`, `audit`, and `fire_and_forget` plugins cannot. +Whether a plugin *can* block depends on its [execution mode]({{< relref "/docs/0.1.x/execution-modes" >}}). `sequential` and `concurrent` plugins can block; `transform`, `audit`, and `fire_and_forget` plugins cannot. --- @@ -225,4 +227,4 @@ if not result.continue_processing: ## Next Steps -Now that you understand hooks, explore the [built-in hook types]({{< relref "/docs/hook-types" >}}) or learn how [execution modes]({{< relref "/docs/execution-modes" >}}) control plugin behavior. +Now that you understand hooks, explore the [built-in hook types]({{< relref "/docs/0.1.x/hook-types" >}}) or learn how [execution modes]({{< relref "/docs/0.1.x/execution-modes" >}}) control plugin behavior. diff --git a/docs/content/docs/isolated-plugins.md b/docs/content/docs/0.1.x/isolated-plugins.md similarity index 95% rename from docs/content/docs/isolated-plugins.md rename to docs/content/docs/0.1.x/isolated-plugins.md index ecc0c0ac..6c4c658f 100644 --- a/docs/content/docs/isolated-plugins.md +++ b/docs/content/docs/0.1.x/isolated-plugins.md @@ -1,6 +1,8 @@ --- title: "Isolated Plugins" weight: 80 +aliases: + - /docs/isolated-plugins/ --- # Isolated Plugins (venv) @@ -111,4 +113,4 @@ The process boundary means the plugin has full access to its own dependency tree | Language support | Python only | Python only | Any (via protocol) | | Scaling | In-process | In-process | Independent | -Use **native** when you control the dependencies. Use **isolated** when you need Python-level isolation without the operational complexity of running a separate service. Use **[external]({{< relref "/docs/external-plugins" >}})** when you need full process isolation, independent scaling, or non-Python implementations. +Use **native** when you control the dependencies. Use **isolated** when you need Python-level isolation without the operational complexity of running a separate service. Use **[external]({{< relref "/docs/0.1.x/external-plugins" >}})** when you need full process isolation, independent scaling, or non-Python implementations. diff --git a/docs/content/docs/0.1.x/overview.md b/docs/content/docs/0.1.x/overview.md new file mode 100644 index 00000000..9bd69675 --- /dev/null +++ b/docs/content/docs/0.1.x/overview.md @@ -0,0 +1,45 @@ +--- +title: "Overview" +weight: 10 +--- + +# Overview + +## Why CPEX? + +AI systems interact with tools, APIs, data sources, and other agents. Adding guardrails, observability, or policy checks typically means embedding that logic directly into application code — leading to duplication, tight coupling, and drift. + +CPEX introduces **standardized interception hooks** between your application and its operations. Plugins attach to these hooks and run automatically, keeping enforcement logic separate from business logic. + +## How It Works + +Your application defines **hooks** — named interception points before and after critical operations. Plugins register against these hooks and execute automatically when triggered. The plugin manager handles registration, ordering, execution, timeouts, and error isolation. + +```goat + .---. .----. .-------. .------. .---. + | App +----->| Hook +----->| Manager +------>| Result +----->| App | + '---' '----' '---+---' '------' '---' + | + .-----------+-----------. + | | | + v v v + .--------. .--------. .--------. + | Plugin A | | Plugin B | | Plugin C | + '--------' '--------' '--------' +``` + +When a hook fires, the plugin manager dispatches the payload to every registered plugin in priority order. Each plugin can: + +- **Allow** execution to continue unchanged +- **Modify** the payload (e.g., redact sensitive data, inject defaults) +- **Block** execution with a violation (e.g., deny a prohibited tool call) + +You get a deterministic pipeline with no surprises. + +## Built-in Hooks + +CPEX ships with hooks for common AI operations — tools, prompts, resources, agents, HTTP requests, identity resolution, and a unified Common Message Format for cross-cutting policy evaluation. You can also [register your own hooks]({{< relref "/docs/0.1.x/hooks#custom-hooks" >}}) for any domain. + +## Next Steps + +Ready to build? The [Quick Start]({{< relref "/docs/0.1.x/quickstart" >}}) gets you a working plugin in five minutes. diff --git a/docs/content/docs/package-integrity.md b/docs/content/docs/0.1.x/package-integrity.md similarity index 99% rename from docs/content/docs/package-integrity.md rename to docs/content/docs/0.1.x/package-integrity.md index 66dda63f..e775f837 100644 --- a/docs/content/docs/package-integrity.md +++ b/docs/content/docs/0.1.x/package-integrity.md @@ -1,6 +1,8 @@ --- title: "Package Integrity Verification" weight: 150 +aliases: + - /docs/package-integrity/ --- # Package Integrity Verification diff --git a/docs/content/docs/0.1.x/patterns.md b/docs/content/docs/0.1.x/patterns.md new file mode 100644 index 00000000..dc92d8b6 --- /dev/null +++ b/docs/content/docs/0.1.x/patterns.md @@ -0,0 +1,256 @@ +--- +title: "Patterns & Best Practices" +weight: 110 +--- + +# Patterns & Best Practices + +Curated patterns for building production plugin pipelines. + +--- + +## Layered Security Pipeline + +Compose modes and priorities to build defense-in-depth. Each layer has a specific responsibility: + +```yaml +plugins: + # Layer 1: hard enforcement — blocks requests that violate policy + - name: token_budget + kind: security.TokenBudgetPlugin + mode: sequential + priority: 10 + hooks: [tool_pre_invoke] + + # Layer 2: content policy — blocks prohibited content + - name: content_policy + kind: security.ContentPolicyPlugin + mode: sequential + priority: 20 + hooks: [tool_pre_invoke, agent_pre_invoke] + + # Layer 3: transformation — redacts PII without blocking + - name: pii_redactor + kind: privacy.PIIRedactionPlugin + mode: transform + priority: 30 + hooks: [tool_pre_invoke, tool_post_invoke] + + # Layer 4: background logging — never blocks or slows + - name: audit_logger + kind: observability.AuditLogPlugin + mode: fire_and_forget + priority: 100 + hooks: [tool_pre_invoke, tool_post_invoke, prompt_pre_fetch] +``` + +Execution order: `token_budget` (sequential) → `content_policy` (sequential) → `pii_redactor` (transform) → `audit_logger` (fire_and_forget). Each layer can only do what its mode permits. + +--- + +## Graceful Policy Rollout with Audit Mode + +Deploy new policies safely by starting in `audit` mode. Violations are logged but don't block traffic: + +```yaml + - name: new_content_policy_v2 + kind: experimental.ContentPolicyV2 + mode: audit # observe only — no blocking, no modifications + priority: 15 + hooks: [tool_pre_invoke] +``` + +Monitor your logs for violations. When you're confident the policy is tuned correctly, promote to `sequential`: + +```yaml + mode: sequential # now enforcing +``` + +This gives you zero-risk rollout for any new policy. + +--- + +## Input/Output Guardrails + +Apply the same `transform` plugin to both pre- and post-invoke hooks to sanitize inputs and outputs: + +```python +import re + +from cpex.framework import Plugin, PluginConfig, PluginContext, ToolPreInvokePayload, ToolPreInvokeResult, ToolPostInvokePayload, ToolPostInvokeResult + +CREDIT_CARD = re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b") + + +class PIIGuardrailPlugin(Plugin): + async def tool_pre_invoke( + self, payload: ToolPreInvokePayload, context: PluginContext + ) -> ToolPreInvokeResult: + if not payload.args: + return ToolPreInvokeResult(continue_processing=True) + cleaned = { + k: CREDIT_CARD.sub("[CARD-REDACTED]", v) if isinstance(v, str) else v + for k, v in payload.args.items() + } + return ToolPreInvokeResult( + continue_processing=True, + modified_payload=payload.model_copy(update={"args": cleaned}), + ) + + async def tool_post_invoke( + self, payload: ToolPostInvokePayload, context: PluginContext + ) -> ToolPostInvokeResult: + if isinstance(payload.result, str): + cleaned = CREDIT_CARD.sub("[CARD-REDACTED]", payload.result) + return ToolPostInvokeResult( + continue_processing=True, + modified_payload=payload.model_copy(update={"result": cleaned}), + ) + return ToolPostInvokeResult(continue_processing=True) +``` + +Configure with `mode: transform` so the plugin can modify payloads but never accidentally block the pipeline. + +--- + +## Cross-Hook State + +Use `PluginContext.state` to pass data between hooks within the same request lifecycle. The context persists across pre- and post-invoke hooks for the same request: + +```python +import time + +from cpex.framework import Plugin, PluginContext, ToolPreInvokePayload, ToolPreInvokeResult, ToolPostInvokePayload, ToolPostInvokeResult + + +class LatencyTrackerPlugin(Plugin): + async def tool_pre_invoke( + self, payload: ToolPreInvokePayload, context: PluginContext + ) -> ToolPreInvokeResult: + context.set_state("start_time", time.monotonic()) + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke( + self, payload: ToolPostInvokePayload, context: PluginContext + ) -> ToolPostInvokeResult: + start = context.get_state("start_time") + if start: + elapsed_ms = (time.monotonic() - start) * 1000 + context.set_state("tool_latency_ms", elapsed_ms) + return ToolPostInvokeResult(continue_processing=True) +``` + +--- + +## Config-Driven Deny/Allow Lists + +Drive plugin behavior from YAML config — no code changes needed to update the rules: + +```python +from cpex.framework import Plugin, PluginConfig, PluginContext, PluginViolation, ToolPreInvokePayload, ToolPreInvokeResult + + +class ToolAllowListPlugin(Plugin): + def __init__(self, config: PluginConfig): + super().__init__(config) + self._allowed = set((config.config or {}).get("allowed_tools", [])) + + async def tool_pre_invoke( + self, payload: ToolPreInvokePayload, context: PluginContext + ) -> ToolPreInvokeResult: + if self._allowed and payload.name not in self._allowed: + return ToolPreInvokeResult( + continue_processing=False, + violation=PluginViolation( + reason=f"Tool '{payload.name}' not in allow list", + description="Only explicitly allowed tools may be invoked.", + code="TOOL_NOT_ALLOWED", + ), + ) + return ToolPreInvokeResult(continue_processing=True) +``` + +```yaml + - name: tool_allowlist + kind: security.ToolAllowListPlugin + mode: sequential + priority: 5 + hooks: [tool_pre_invoke] + config: + allowed_tools: + - web_search + - calculator + - file_read +``` + +--- + +## Plugin-Specific Config with Pydantic + +Validate your plugin's `config` dict at init time using a Pydantic model. This gives you type safety, default values, and clear error messages: + +```python +from pydantic import BaseModel +from cpex.framework import Plugin, PluginConfig + + +class RateLimitConfig(BaseModel): + requests_per_minute: int = 60 + burst_size: int = 10 + scope: str = "user" # "user" or "global" + + +class RateLimitPlugin(Plugin): + def __init__(self, config: PluginConfig): + super().__init__(config) + self._settings = RateLimitConfig.model_validate(config.config or {}) +``` + +If the YAML provides an invalid value (e.g., `requests_per_minute: "not_a_number"`), Pydantic raises a validation error at plugin initialization rather than at runtime. + +--- + +## Idempotent Initialize and Shutdown + +Make `initialize()` and `shutdown()` safe to call multiple times: + +```python +class MyPlugin(Plugin): + def __init__(self, config): + super().__init__(config) + self._client = None + + async def initialize(self): + if self._client is None: + self._client = await create_client() + + async def shutdown(self): + if self._client is not None: + await self._client.close() + self._client = None +``` + +The plugin manager may call these methods more than once during lifecycle transitions. Guard against double-initialization and double-cleanup. + +--- + +## Observability Stack + +Use `fire_and_forget` plugins for telemetry that must never slow the pipeline: + +```yaml +plugins: + - name: request_tracer + kind: observability.RequestTracerPlugin + mode: fire_and_forget + priority: 100 + hooks: [tool_pre_invoke, tool_post_invoke, prompt_pre_fetch, prompt_post_fetch] + + - name: metrics_collector + kind: observability.MetricsPlugin + mode: fire_and_forget + priority: 101 + hooks: [tool_pre_invoke, tool_post_invoke] +``` + +These plugins receive an isolated snapshot of the payload, run asynchronously in the background, and their exceptions are logged but never propagated. The main pipeline is unaffected even if a telemetry backend is down. diff --git a/docs/content/docs/0.1.x/quickstart.md b/docs/content/docs/0.1.x/quickstart.md new file mode 100644 index 00000000..2e041671 --- /dev/null +++ b/docs/content/docs/0.1.x/quickstart.md @@ -0,0 +1,167 @@ +--- +title: "Quick Start" +weight: 20 +--- + +# Your First Plugin in 5 Minutes + +This guide walks you through installing CPEX, writing a plugin, configuring it, and running it. + +## Install + +```bash +pip install cpex +``` + +## What Are Plugins? + +Plugins let you intercept and modify execution at well-defined points — without changing the targeted application code. + +You define **hooks** in your application where you want extensibility. Plugins attach to those hooks and run automatically whenever they fire. + +## 1. Write a Plugin + +A plugin is a class that subclasses `Plugin` and implements one or more hook handlers. Here you will create a plugin that blocks specific tools by name. + +Create a file `plugins/tool_blocker.py`: + +```python +import logging + +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + PluginViolation, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + +log = logging.getLogger(__name__) + + +class ToolBlockerPlugin(Plugin): + def __init__(self, config: PluginConfig): + super().__init__(config) + self._blocked = set(config.config.get("blocked_tools", [])) + + async def tool_pre_invoke( + self, payload: ToolPreInvokePayload, context: PluginContext + ) -> ToolPreInvokeResult: + if payload.name in self._blocked: + log.warning("Blocked tool: %s", payload.name) + return ToolPreInvokeResult( + continue_processing=False, + violation=PluginViolation( + reason=f"Tool '{payload.name}' is not allowed", + description="This tool has been blocked by policy.", + code="TOOL_BLOCKED", + ), + ) + return ToolPreInvokeResult(continue_processing=True) +``` + +The method name `tool_pre_invoke` matches the hook name — CPEX discovers it automatically. No decorator needed. + +## 2. Configure the Plugin + +Create `plugins/config.yaml`: + +```yaml +plugin_dirs: + - ./plugins + +plugins: + - name: tool_blocker + kind: plugins.tool_blocker.ToolBlockerPlugin + version: "1.0.0" + hooks: + - tool_pre_invoke + mode: sequential + priority: 10 + config: + blocked_tools: + - dangerous_tool + - admin_delete +``` + +Key fields: + +- **`kind`** — fully qualified class path to your plugin +- **`hooks`** — which hook points this plugin handles +- **`mode`** — execution mode (`sequential` lets you block *and* modify) +- **`priority`** — lower numbers run first (10 runs before 100) +- **`config`** — plugin-specific settings passed to your constructor + +## 3. Run the Pipeline + +```python +import asyncio +from cpex.framework import ( + GlobalContext, + PluginManager, + ToolPreInvokePayload, +) + + +async def main(): + manager = PluginManager("plugins/config.yaml") + await manager.initialize() + + payload = ToolPreInvokePayload(name="dangerous_tool", args={"target": "production"}) + context = GlobalContext(request_id="req-001", user="alice") + + result, _ = await manager.invoke_hook("tool_pre_invoke", payload, context) + + if result.continue_processing: + print("Allowed — proceed with tool call") + else: + print(f"Blocked: {result.violation.reason}") + # Output: Blocked: Tool 'dangerous_tool' is not allowed + + await manager.shutdown() + + +asyncio.run(main()) +``` + +That's it. Three files — a plugin, a config, and a driver — and you have a working enforcement pipeline. + +--- + +## Alternative: The `@hook` Decorator + +If you want the method name to differ from the hook name, use the `@hook` decorator: + +```python +from cpex.framework import hook, Plugin, PluginContext, ToolPreInvokePayload, ToolPreInvokeResult + + +class ToolBlockerPlugin(Plugin): + @hook("tool_pre_invoke") + async def check_tool_access( + self, payload: ToolPreInvokePayload, context: PluginContext + ) -> ToolPreInvokeResult: + # same logic as before + return ToolPreInvokeResult(continue_processing=True) +``` + +The decorator is also useful when a single plugin handles multiple hooks — you can give each method a descriptive name without worrying about naming collisions. + +--- + +## Using `get_plugin_manager` + +For applications that configure CPEX through environment variables (`PLUGINS_ENABLED=true`, `PLUGINS_CONFIG_FILE=plugins/config.yaml`), you can use the singleton helper instead of constructing the manager directly: + +```python +from cpex.framework import get_plugin_manager + +manager = get_plugin_manager() +if manager: + await manager.initialize() +``` + +## Next Steps + +Now that you have a working plugin, learn how hooks work in detail: [Hooks]({{< relref "/docs/0.1.x/hooks" >}}). diff --git a/docs/content/docs/0.1.x/testing.md b/docs/content/docs/0.1.x/testing.md new file mode 100644 index 00000000..fb44ee4d --- /dev/null +++ b/docs/content/docs/0.1.x/testing.md @@ -0,0 +1,233 @@ +--- +title: "Testing Plugins" +weight: 120 +--- + +# Testing Plugins + +Plugins are plain async classes — you can test them directly without the full framework. For integration testing, use `PluginManager` with a test configuration. + +--- + +## Unit Testing + +Call hook methods directly with constructed payloads and contexts. No framework overhead needed. + +```python +import pytest + +from cpex.framework import ( + GlobalContext, + PluginConfig, + PluginContext, + ToolPreInvokePayload, +) + + +@pytest.mark.asyncio +async def test_tool_blocker_blocks_dangerous_tool(): + config = PluginConfig( + name="test_blocker", + kind="plugins.tool_blocker.ToolBlockerPlugin", + version="1.0.0", + hooks=["tool_pre_invoke"], + config={"blocked_tools": ["dangerous_tool", "admin_delete"]}, + ) + + # Import your plugin class + from plugins.tool_blocker import ToolBlockerPlugin + + plugin = ToolBlockerPlugin(config) + + payload = ToolPreInvokePayload(name="dangerous_tool", args={"target": "prod"}) + context = PluginContext(global_context=GlobalContext(request_id="test-001")) + + result = await plugin.tool_pre_invoke(payload, context) + + assert result.continue_processing is False + assert result.violation is not None + assert result.violation.code == "TOOL_BLOCKED" +``` + +### Testing Allowed Requests + +```python +@pytest.mark.asyncio +async def test_tool_blocker_allows_safe_tool(): + config = PluginConfig( + name="test_blocker", + kind="plugins.tool_blocker.ToolBlockerPlugin", + version="1.0.0", + hooks=["tool_pre_invoke"], + config={"blocked_tools": ["dangerous_tool"]}, + ) + + from plugins.tool_blocker import ToolBlockerPlugin + + plugin = ToolBlockerPlugin(config) + + payload = ToolPreInvokePayload(name="web_search", args={"query": "CPEX docs"}) + context = PluginContext(global_context=GlobalContext(request_id="test-002")) + + result = await plugin.tool_pre_invoke(payload, context) + + assert result.continue_processing is True + assert result.violation is None +``` + +### Testing Payload Modification + +```python +@pytest.mark.asyncio +async def test_pii_redaction_removes_emails(): + config = PluginConfig( + name="test_redactor", + kind="plugins.pii.PIIRedactionPlugin", + version="1.0.0", + hooks=["tool_pre_invoke"], + ) + + from plugins.pii import PIIRedactionPlugin + + plugin = PIIRedactionPlugin(config) + + payload = ToolPreInvokePayload( + name="send_email", + args={"body": "Contact alice@example.com for details"}, + ) + context = PluginContext(global_context=GlobalContext(request_id="test-003")) + + result = await plugin.redact_pii(payload, context) + + assert result.continue_processing is True + assert result.modified_payload is not None + assert "alice@example.com" not in result.modified_payload.args["body"] + assert "[REDACTED]" in result.modified_payload.args["body"] +``` + +--- + +## Integration Testing + +Use `PluginManager` with a test configuration to verify the full pipeline — mode ordering, priority, chaining, and condition matching. + +```python +import tempfile +from pathlib import Path + +import pytest +import yaml + +from cpex.framework import GlobalContext, PluginManager, ToolPreInvokePayload + + +@pytest.fixture +async def manager(tmp_path): + config = { + "plugin_dirs": ["./plugins"], + "plugins": [ + { + "name": "blocker", + "kind": "plugins.tool_blocker.ToolBlockerPlugin", + "version": "1.0.0", + "hooks": ["tool_pre_invoke"], + "mode": "sequential", + "priority": 10, + "config": {"blocked_tools": ["dangerous_tool"]}, + }, + { + "name": "redactor", + "kind": "plugins.pii.PIIRedactionPlugin", + "version": "1.0.0", + "hooks": ["tool_pre_invoke"], + "mode": "transform", + "priority": 20, + }, + ], + } + + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(config)) + + mgr = PluginManager(str(config_path)) + await mgr.initialize() + yield mgr + await mgr.shutdown() + PluginManager.reset() + + +@pytest.mark.asyncio +async def test_pipeline_blocks_before_transform(manager): + payload = ToolPreInvokePayload(name="dangerous_tool", args={"data": "alice@example.com"}) + context = GlobalContext(request_id="test-pipeline") + + result, _ = await manager.invoke_hook("tool_pre_invoke", payload, context) + + # Sequential blocker runs first (priority 10) and halts the pipeline + assert result.continue_processing is False + assert result.violation.code == "TOOL_BLOCKED" + + +@pytest.mark.asyncio +async def test_pipeline_chains_transform(manager): + payload = ToolPreInvokePayload( + name="web_search", + args={"query": "contact alice@example.com"}, + ) + context = GlobalContext(request_id="test-chain") + + result, _ = await manager.invoke_hook("tool_pre_invoke", payload, context) + + # Blocker allows (not in blocked list), redactor transforms + assert result.continue_processing is True + if result.modified_payload: + assert "alice@example.com" not in result.modified_payload.args["query"] +``` + +--- + +## Important: Reset Between Tests + +`PluginManager` uses a Borg singleton pattern — all instances share state. Always call `PluginManager.reset()` in your teardown to clear shared state between tests: + +```python +@pytest.fixture(autouse=True) +def reset_manager(): + yield + PluginManager.reset() +``` + +--- + +## Testing with `invoke_hook_for_plugin` + +To test a specific plugin in isolation within the manager (bypassing priority ordering), use `invoke_hook_for_plugin`: + +```python +@pytest.mark.asyncio +async def test_specific_plugin(manager): + payload = ToolPreInvokePayload(name="calculator", args={"a": "5"}) + context = GlobalContext(request_id="test-specific") + + result = await manager.invoke_hook_for_plugin( + name="redactor", + hook_type="tool_pre_invoke", + payload=payload, + context=context, + ) + + assert result.continue_processing is True +``` + +--- + +## Pytest Configuration + +All hook methods are async, so you need `pytest-asyncio`. Add to your `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +``` + +Or mark individual tests with `@pytest.mark.asyncio`. diff --git a/docs/content/docs/0.1.x/vision.md b/docs/content/docs/0.1.x/vision.md new file mode 100644 index 00000000..645a7d72 --- /dev/null +++ b/docs/content/docs/0.1.x/vision.md @@ -0,0 +1,90 @@ +--- +title: "Vision" +weight: 5 +--- + +# Universal Extensibility for AI Security + +AI agents execute across trust domains, calling tools, accessing data, and delegating to other agents. No single policy engine or enforcement point is sufficient. The execution path spans LLM proxies, agent frameworks, gateways, and external services. Security policies must be injected across the entire stack. + +CPEX is the **composable enforcement framework** that makes this possible. + +--- + +## Hooks Are the Enforcement Plane + +Hooks are standardized interception points placed at every boundary where an agent acts, before and after tool calls, LLM completions, prompt fetches, and protocol messages. Plugins attach to hooks and run automatically, keeping enforcement logic separate from business logic. + +This architecture deploys identically across the stack, inside LLM proxies, agent frameworks, and gateways. Each layer runs its own plugins. Prompt injection detection at the proxy. Tool authorization at the gateway. Data loss prevention at the agent. + +![CPEX hooks deployed across the agent stack](/cpex/images/distributed_hooks_control_plane.png) + +--- + +## Hooks Need Policy. Policy Needs Context. + +Enforcement is a three-layer problem. + +| Layer | Role | +|-------|------| +| **Hooks** | Where enforcement happens. Interception, decision, transformation. | +| **CMF** (Common Message Format) | What you evaluate. A protocol-agnostic context envelope carrying identity, security labels, delegation chains, and content. | +| **APL** (Attribute Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | + +![Hooks, CMF, and APL form a unified enforcement stack](/cpex/images/overview_vision.png) + +Hooks make enforcement **possible**. Policy makes it **usable**. Context makes it **correct**. + +--- + +## The Policy Spectrum + +Different policy types require different enforcement points. CPEX provides hooks at every layer, from soft stylistic policies enforced at the prompt level to hard compliance requirements enforced at infrastructure boundaries. + +![Policy spectrum: each policy type maps to a different enforcement point](/cpex/images/policy_spectrum.png) + +--- + +## How It Works + +An application or framework invokes a hook at a critical operation boundary. The plugin manager dispatches registered plugins (sequentially, concurrently, or fire-and-forget) and returns a result. Plugins can **allow** execution to continue, **block** it with a violation, or **modify** the payload using copy-on-write isolation. + +![Plugin execution model: agent → middleware → hook → manager → plugins](/cpex/images/integration_execution_model.png) + +The plugin manager handles registration, ordering, timeouts, error isolation, and payload chaining. You get a deterministic enforcement pipeline with no surprises. + +--- + +## Where We're Going + +CPEX is under active development. The current Python framework is production-ready. The roadmap extends the core in several directions. + +- **Rust core.** A shared plugin execution engine with type-safe CMF invariant enforcement, replacing convention-based rules with compile-time guarantees. Python (PyO3) and Go (cgo) bindings enable a single runtime across language consumers. + +- **WASM sandboxing.** Portable, capability-based isolation for third-party plugins. Zero-trust by default: no filesystem, network, or host memory unless explicitly granted. + +- **APL integration.** Declarative policy pipelines that compose built-in attribute checks with external policy engines (OPA, Cedar, AuthZEN, NeMo Guardrails) in a single evaluation. + +- **Plugin catalog.** Discovery, versioning, and installation of plugins from registries. Multiple instances from a single manifest, managed through the CLI. + +See the [GitHub milestones](https://github.com/contextforge-org/cpex/milestones) and [open issues](https://github.com/contextforge-org/cpex/issues) for details. + +--- + +## Projects Using CPEX + +| Project | Description | +|---------|-------------| +| [ContextForge](https://github.com/IBM/mcp-context-forge) | MCP gateway with CPEX enforcement built in | +| [Mellea](https://github.com/generative-computing/mellea) | Agentic framework with CPEX plugin integration | + +--- + +## Get Involved + +CPEX is part of the [ContextForge](https://github.com/contextforge-org) ecosystem. + +- [CPEX Plugin Framework](https://github.com/contextforge-org/cpex) (this project) +- [Contributing Guide](https://github.com/contextforge-org/cpex/blob/main/CONTRIBUTING.md) + +Contributions, feedback, and plugin ideas are welcome. Open an issue or submit a pull request. diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index e88200fe..b9e4345d 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -6,4 +6,8 @@ bookFlatSection: true # CPEX Documentation -Learn how to build extensible AI systems with CPEX — from understanding the core concepts to deploying production plugin pipelines. +CPEX is a policy and authorization framework for agentic applications: a deterministic reference monitor that mediates every operation an untrusted LLM triggers, enforcing policy written in APL (Authorization Policy Language). + +Start with the [Vision]({{< relref "/docs/vision" >}}) for the reference-monitor model, the [Quick Start]({{< relref "/docs/quickstart" >}}) to stand up an enforcement point, or [APL]({{< relref "/docs/apl" >}}) to write policy. + +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 new file mode 100644 index 00000000..1e1ff1d1 --- /dev/null +++ b/docs/content/docs/apl/_index.md @@ -0,0 +1,122 @@ +--- +title: "APL" +weight: 30 +--- + +# Authorization Policy Language + +APL is how you express policy in CPEX. A policy is declarative: you describe the conditions and the effects, attached to the operation they govern, and CPEX evaluates them at the boundary. You do not write enforcement logic in application code. + +This page covers the language: routes, phases, predicates, rules, and field pipelines. The rest of this section goes deeper on each kind of policy: + +- [Effects & Sequencing]({{< relref "/docs/apl/effects" >}}): the effects a rule can run, halt-on-deny ordering, and composition. +- [PDP Integration]({{< relref "/docs/apl/pdp" >}}): hand a decision to Cedar, CEL, or an external engine. +- [Identity & IdP]({{< relref "/docs/apl/identity" >}}): how callers are resolved into the attributes predicates read. +- [Delegation]({{< relref "/docs/apl/delegation" >}}): mint scoped downstream credentials via token exchange. +- [Session Tainting]({{< relref "/docs/apl/tainting" >}}): information-flow control across requests. + +## Routes and phases + +Policy is organized by **route**: an operation CPEX mediates, identified by the tool, A2A method, or other interface it governs. Each route runs through four phases, in order: + +```mermaid +flowchart LR + ARGS["args
validate / transform input"] --> POL["policy
authorize"] --> RES["result
transform output"] --> POST["post_policy
audit / final checks"] +``` + +- **args**: validate and transform request inputs before the operation runs. +- **policy**: authorize the operation. Predicates, PDP calls, delegation, tainting. +- **result**: transform the response. Redaction and masking on the wire. +- **post_policy**: checks after the result is known. Audit, post-delegation verification. + +The first `deny` in any phase halts that phase and every later phase. Nothing reaches the backend after a deny in `args` or `policy`. + +```yaml +routes: + - tool: get_employee + args: + employee_id: "str" + policy: + - "require(authenticated)" + - "delegation.depth > 2: deny" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" +``` + +## Predicates + +A predicate reads attributes resolved from the caller's identity and request context (see [Identity]({{< relref "/docs/apl/identity" >}}) for where attributes come from). The forms: + +- **Truthiness**: a bare attribute is true when present and truthy. `authenticated`, `role.hr`, `perm.view_ssn`. +- **Comparison**: `delegation.depth > 2`, `client.trust_level == 'trusted'`. Operators: `==`, `!=`, `>`, `>=`, `<`, `<=`. +- **Set membership**: `subject.id in authorized_users`, `subject.id not in banned_list`. +- **Existence**: `exists(delegation.origin_subject_id)` is true when the attribute is present. +- **Containment**: `security.labels contains "secret"`. +- **Logical composition**: `&` (and), `|` (or), `!` (not). Precedence is `()` > `!` > `&` > `|`. + +```yaml +- "(role.hr | role.security) & !delegated" +``` + +## Rules + +A `policy:` (or `post_policy:`) entry is a rule. Two forms: + +**`require(...)`** denies unless the predicate holds: + +```yaml +- "require(authenticated)" +- "require(role.hr)" +- "require(!delegated)" +``` + +`require(a, b)` denies if either is false (an implicit and). `require(a | b)` denies only if both are false. + +**`predicate: effect`** runs the effect when the predicate holds: + +```yaml +- "delegation.depth > 2: deny" +- "security.labels contains \"secret\": deny('session touched secret data', 'session_tainted')" +``` + +`deny` takes an optional reason and code: `deny`, `deny('reason')`, or `deny('reason', 'code')`. The code is surfaced to the caller and the audit log. + +For richer conditionals, use the `when` / `do` form, where `do` is a single effect or a list: + +```yaml +- when: "role.hr & !perm.view_ssn" + do: + - "taint(restricted, session)" + - "plugin(audit-log)" +``` + +## Field pipelines + +`args:` and `result:` map a field to a pipeline of stages separated by `|`. Stages run left to right; a failed validator denies the phase. + +```yaml +result: + ssn: "str | redact(!perm.view_ssn)" + email: "email" + employee_id: "str | mask(4)" +``` + +The accepted stages: + +| Category | Stages | +|----------|--------| +| Type validators | `str`, `int`, `bool`, `float`, `email`, `url`, `uuid` | +| 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])` | + +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. + +## Effects beyond predicates + +A `policy:` 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. diff --git a/docs/content/docs/apl/delegation.md b/docs/content/docs/apl/delegation.md new file mode 100644 index 00000000..c2cf467b --- /dev/null +++ b/docs/content/docs/apl/delegation.md @@ -0,0 +1,72 @@ +--- +title: "Delegation" +weight: 40 +--- + +# Delegation and Token Exchange + +When CPEX forwards an operation to a backend, the backend needs a credential. Forwarding the caller's inbound token is usually wrong: it is scoped for the agent, not the backend, and it carries more privilege than the operation needs. Delegation mints a fresh, narrowly scoped credential for the specific downstream call. + +## The requirement + +The scenario's `get_compensation` reads from a backend HR system that expects its own audience-scoped token with only the `read_compensation` scope. The caller never holds that token. CPEX must exchange the caller's verified identity for a downstream credential, scoped down to exactly what the operation needs, and only after authorization has passed. + +## Delegation as an effect + +`delegate` is an effect in the `policy` phase. It names a delegator plugin and the target it mints for: + +```yaml +policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" + - "delegation.granted.permissions contains 'read_compensation': allow" +``` + +The order matters. The `require` gate runs first, so a credential is only minted for a caller who passed authorization. After the exchange, a post-check verifies the credential actually carries the scope requested, and denies the operation if the IdP returned less. + +```mermaid +flowchart LR + IN["caller's verified token
(audience: agent)"] --> DEL["delegate(workday-oauth)"] + DEL -->|"RFC 8693 token exchange"| IDP["IdP token endpoint"] + IDP --> OUT["downstream token
(audience: workday-api
scope: read_compensation)"] + OUT --> BE["backend"] + CHK["delegation.granted.permissions
verified before forward"] -.-> OUT +``` + +## The delegator plugin + +The bundled `delegator/oauth` plugin performs RFC 8693 token exchange against an IdP token endpoint: + +```yaml +plugins: + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + config: + token_endpoint: "https://idp.example.com/realms/agents/protocol/openid-connect/token" + client_id: "cpex-gateway" + client_secret_source: + kind: file + path: /etc/cpex-secrets/client-secret + default_outbound_header: "Authorization" +``` + +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. + +## Delegation attributes + +After a `delegate` effect, policy can read the outcome and the delegation context: + +| Attribute | Meaning | +|-----------|---------| +| `delegation.granted.permissions` | Scopes the IdP actually granted on the minted token. | +| `delegation.depth` | How many delegations deep this request is. | +| `delegated` | True when the request is acting under a delegated credential. | +| `delegation.origin_subject_id` | The original subject at the head of the chain. | +| `delegation.actor_subject_id` | The acting subject for this hop. | + +These let policy reason about the chain itself, for example `require(delegation.depth <= 1)` to refuse deeply nested delegation, or the post-check above to enforce least privilege on what was actually granted. + +## How it connects to the pipeline + +`delegate` dispatches to a plugin implementing the `token.delegate` hook. The minted credential is recorded in the request's delegation context and the audit log. Because delegation is an explicit effect rather than a side effect of forwarding, it is sequenced like any other effect: gated behind authorization, followed by verification, and halted on error when configured with `on_error: deny`. diff --git a/docs/content/docs/apl/effects.md b/docs/content/docs/apl/effects.md new file mode 100644 index 00000000..5a50719b --- /dev/null +++ b/docs/content/docs/apl/effects.md @@ -0,0 +1,69 @@ +--- +title: "Effects & Sequencing" +weight: 10 +--- + +# Effects and Sequencing + +An APL rule does something. That something is an **effect**. Effects are the building blocks of policy: a `policy:` block is an ordered list of them, and they run in sequence until one denies. + +## The effects + +| Effect | What it does | +|--------|--------------| +| `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). | +| `delegate(name, ...)` | Mint a downstream credential via a delegator plugin. See [Delegation]({{< relref "/docs/apl/delegation" >}}). | +| `taint(label[, scope])` | Attach a label to the session or message. See [Session Tainting]({{< relref "/docs/apl/tainting" >}}). | +| 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" >}}). | + +## Sequencing and halt-on-deny + +Effects in a `policy:` block run top to bottom. The first `deny` halts the phase and skips every later phase, so order is a tool: put cheap gates first and expensive effects last. + +```yaml +policy: + - "require(role.hr)" # cheap attribute gate + - cedar: # relationship decision + action: 'Action::"read"' + resource: { type: Repo, id: ${args.repo_name} } + - "delegate(github-oauth, target: github-api, permissions: [repo:read])" # expensive, last +``` + +If `require(role.hr)` denies, the Cedar call and the token exchange never run. This is both faster and safer: you do not mint a credential for a caller you were going to reject. + +## Reactions: on_allow and on_deny + +A PDP call can carry reaction blocks that run depending on the decision: + +```yaml +policy: + - cedar: + action: 'Action::"read"' + resource: { type: Document, id: ${args.doc_id} } + on_allow: + - "taint(cedar_approved, session)" + on_deny: + - "deny('not permitted by Cedar policy', 'cedar_denied')" +``` + +`on_allow` runs its effects when the PDP permits; `on_deny` runs when it denies. Without an `on_deny`, a PDP denial halts the phase on its own. + +## Composition: sequential and parallel + +Effects can be grouped. `sequential` runs its members in order and halts on the first deny. `parallel` runs independent gates concurrently; any deny fails the group, and taints from the branches accumulate. + +```yaml +policy: + - parallel: + - "require(perm.read_pii)" + - cel: { expr: "subject.department == 'compliance'" } +``` + +`parallel` is for independent decisions only. It rejects field operations and delegation, because a discarded branch would silently lose those effects. Use `sequential` (the default for a `policy:` list) whenever one effect depends on another. + +## Phases recap + +Effects run within the four route phases: `args`, `policy`, `result`, `post_policy` (see [APL]({{< relref "/docs/apl" >}})). `delegate` and PDP calls belong in `policy` or `post_policy`; field pipelines belong in `args` and `result`. A deny anywhere halts the rest. diff --git a/docs/content/docs/apl/identity.md b/docs/content/docs/apl/identity.md new file mode 100644 index 00000000..5d5b9818 --- /dev/null +++ b/docs/content/docs/apl/identity.md @@ -0,0 +1,59 @@ +--- +title: "Identity & IdP" +weight: 30 +--- + +# Identity and IdP Integration + +Policy reads attributes: `role.hr`, `perm.view_ssn`, `subject.id`. Those attributes have to come from somewhere trustworthy. They come from identity resolution, which runs before policy and turns a verified credential into the attribute bag that predicates read. + +## The requirement + +The scenario authorizes with `require(role.hr)` and redacts with `redact(!perm.view_ssn)`. For those to mean anything, CPEX must know, for each request, who the caller is and what roles and permissions they hold, established from a token the caller cannot forge, not from anything the LLM said. + +## Resolving identity + +An identity plugin validates an inbound token and populates the subject. The bundled `identity/jwt` plugin verifies a JWT against a trusted issuer and maps its claims into the bag: + +```yaml +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + 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" +``` + +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. + +## What lands in the bag + +A resolved identity populates a flat attribute namespace that predicates read directly: + +| Source | Attributes | +|--------|-----------| +| Subject | `subject.id`, `authenticated`, `subject.teams` | +| Roles | `role.` (for example `role.hr`, `role.security`) | +| Permissions | `perm.

` (for example `perm.view_ssn`) | +| Claims | `claim.` | +| 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. + +## Multiple sources + +A request often carries more than one identity: the end user and the calling application. Register an identity plugin per source. The bundled JWT plugin takes a `role` (`user` or `client`) and a `header`, so a user token on `X-User-Token` and a client token on `Authorization` resolve into the `subject.*` and `client.*` namespaces respectively. Policy can then require both: `require(authenticated) & client.authorized_scopes contains "tools:invoke"`. + +## 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. + +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 new file mode 100644 index 00000000..e2f472eb --- /dev/null +++ b/docs/content/docs/apl/pdp.md @@ -0,0 +1,74 @@ +--- +title: "PDP Integration" +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**. + +## The 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. + +## Calling a PDP from policy + +A PDP call is an effect in the `policy` phase. It names a dialect and passes the request; `on_allow` and `on_deny` react to the decision: + +```yaml +policy: + - "require(team.engineering | team.security)" + - cedar: + action: 'Action::"read"' + resource: + type: Repo + id: ${args.repo_name} + attributes: + visibility: ${args.visibility} + 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: + +```yaml +global: + pdp: + - kind: cedar-direct + policy_text: | + @id("engineering-internal-repos") + permit(principal, action == Action::"read", resource is Repo) + when { + principal.roles.contains("engineer") && + resource.visibility == "internal" + }; + + @id("security-team-any-repo") + permit(principal, action == Action::"read", resource is Repo) + when { principal.roles.contains("security") }; +``` + +## Supported dialects + +APL recognizes a fixed set of PDP dialects. Two ship as builtin resolvers; the rest are recognized by the language and dispatched to a resolver you provide on the host. + +| Dialect | Status | +|---------|--------| +| `cedar` | Ships as the `cedar-direct` builtin resolver. | +| `cel` | Ships as the `cel` builtin resolver (safe, bounded expressions). | +| `opa` | Recognized dialect; wire a host resolver (Rego / OPA). | +| `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 and CEL are provided so you can start without writing one. For OPA, AuthZEN, or NeMo, implement the resolver trait and register it; the APL `opa:` / `authzen:` / `nemo:` call forms then work unchanged. + +CEL is the lightest option for inline boolean policy: + +```yaml +policy: + - cel: { expr: "subject.department == 'compliance' || 'admin' in subject.roles" } +``` + +## 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. diff --git a/docs/content/docs/apl/tainting.md b/docs/content/docs/apl/tainting.md new file mode 100644 index 00000000..8a62002b --- /dev/null +++ b/docs/content/docs/apl/tainting.md @@ -0,0 +1,75 @@ +--- +title: "Session Tainting" +weight: 50 +--- + +# Session Tainting and Information Flow + +Some controls cannot be decided from a single request. "Do not send anything externally after reading secret data" depends on what the session did earlier. CPEX tracks that history as **taint labels**: facts attached to a session that later policy can read. This is how CPEX enforces information-flow control, including write-down prevention. + +## The requirement + +A caller reads compensation data, then asks the agent to send an email. The email body is clean: no SSN, no salary, nothing sensitive in the text. It should still be blocked, because this session has handled secret data and an external send is a write-down. The LLM cannot be trusted to remember this or to refuse on its own, and a content scan of the email body would not catch it. The control has to live in state the model cannot see. + +## Tainting a session + +A `taint` effect attaches a label. The scenario marks the session when compensation is read: + +```yaml +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + - "taint(secret, session)" + result: + ssn: "str | redact(!perm.view_ssn)" +``` + +`taint(secret, session)` records the label `secret` for the rest of the session. Labels are monotonic: once set, they persist. The second argument is the scope. + +| Scope | Lifetime | +|-------|----------| +| `session` | Persists for the whole session, across requests. | +| `message` | Applies to the current message only. | + +## Reading taint in a later policy + +A different route, later in the same session, refuses based on the label, even with a clean payload: + +```yaml +routes: + - tool: send_email + policy: + - "require(perm.email_send)" + - "security.labels contains \"secret\": deny('session touched secret data', 'session_tainted')" +``` + +```mermaid +flowchart LR + R1["get_compensation"] -->|"taint(secret, session)"| S["session labels:
{ secret }"] + R2["send_email
(clean body)"] --> CHK{"labels contains
secret?"} + S -.-> CHK + CHK -->|yes| DENY["deny
session_tainted"] + CHK -->|no| OK["allow"] +``` + +The email is denied because the session is tainted, not because of anything in its body. The decision is made from CPEX-owned state, so the model cannot route around it by rewording the email. + +## Persistence and isolation + +Taint labels are held in a session store. The default is in-process memory; the bundled `valkey` store persists them across processes and restarts: + +```yaml +global: + session_store: + kind: valkey + 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. + +## How it connects to the pipeline + +`taint` is an effect; reading labels is an attribute check (`security.labels contains ...`) like any other predicate. The session store is a registered capability the runtime writes to after a tainting effect and reads from when building the attribute bag. Because both the write and the read happen inside CPEX, the taint history is part of the state the untrusted model cannot forge, which is what makes write-down enforcement reliable rather than advisory. + +See [Effects]({{< relref "/docs/apl/effects" >}}) for how `taint` sequences with other effects, and [Configuration]({{< relref "/docs/configuration" >}}) for session-store options. diff --git a/docs/content/docs/builtins.md b/docs/content/docs/builtins.md new file mode 100644 index 00000000..5d643d4b --- /dev/null +++ b/docs/content/docs/builtins.md @@ -0,0 +1,56 @@ +--- +title: "Builtins" +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`. + +## The 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" >}})). | + +The default session store is in-process memory; no feature or `kind` is needed for it. + +## Cargo features + +```bash +# nothing bundled (engine only) +cargo add cpex + +# the common in-process set: jwt, oauth, pii, audit, cedar, cel +cargo add cpex --features builtins + +# everything, including the Valkey session store +cargo add cpex --features full + +# a granular subset +cargo add cpex --features "jwt,cedar,pii" +``` + +| 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 | + +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. + +## 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. diff --git a/docs/content/docs/cmf.md b/docs/content/docs/cmf.md index a55211a3..9001c4e1 100644 --- a/docs/content/docs/cmf.md +++ b/docs/content/docs/cmf.md @@ -1,167 +1,43 @@ --- title: "Common Message Format" -weight: 50 +weight: 40 --- -# Common Message Format (CMF) +# Common Message Format -The Common Message Format is a canonical message representation for interactions between users, agents, tools, and language models. It lets you write a single plugin that evaluates content at *every* interception point — tool calls, LLM input/output, resource access — using one unified interface. +APL evaluates policy against a request. The Common Message Format (CMF) is the shape of that request: a protocol-agnostic envelope that represents any mediated operation in one structure, so a single policy can apply across tool calls, A2A methods, inference, prompts, and resources without caring which protocol carried them. ---- - -## Why CMF? - -Without CMF, you write separate handlers for each hook type: - -```python -async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context): - # check tool arguments for prohibited content - ... - -async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context): - # check agent messages for prohibited content — same logic, different payload - ... -``` - -With CMF, you write the logic once and register for multiple hook points: - -```python -from cpex.framework import hook, Plugin, PluginContext -from cpex.framework.hooks.message import CmfHookType, MessagePayload, MessageResult - - -class ContentGuardrailPlugin(Plugin): - @hook([CmfHookType.TOOL_PRE_INVOKE, CmfHookType.LLM_INPUT, CmfHookType.LLM_OUTPUT]) - async def evaluate(self, payload: MessagePayload, context: PluginContext) -> MessageResult: - for view in payload.message.iter_views(): - if view.text and self._contains_prohibited_content(view.text): - return MessageResult( - continue_processing=False, - violation=PluginViolation( - reason="Prohibited content detected", - description=f"Content blocked at {payload.hook.value}", - code="CONTENT_BLOCKED", - ), - ) - return MessageResult(continue_processing=True) -``` - ---- - -## Message - -A `Message` is the top-level CMF object representing a single turn in a conversation: - -```python -from cpex.framework.cmf.message import Message, Role, TextContent, ToolCallContentPart, ToolCall - -msg = Message( - role=Role.ASSISTANT, - content=[ - TextContent(text="Let me look that up."), - ToolCallContentPart( - content=ToolCall( - tool_call_id="tc_001", - name="web_search", - arguments={"query": "CPEX framework"}, - ), - ), - ], -) - -msg.role # Role.ASSISTANT -msg.content[0].text # "Let me look that up." -msg.content[1].content.name # "web_search" -``` - -Messages are frozen. Use `model_copy(update={...})` to create modified copies. +## Why a common format -### Fields +Without CMF, a redaction policy for tool results and a redaction policy for LLM output are different code against different payload types, even though they do the same thing. CMF gives every interception point the same representation, so cross-cutting policy is written once and evaluated everywhere. This is what lets the same APL field pipeline redact a field whether it arrived in a tool result or a model completion. -| Field | Type | Description | -|-------|------|-------------| -| `schema_version` | `str` | Schema version (default `"2.0"`) | -| `role` | `Role` | Who is speaking: `SYSTEM`, `DEVELOPER`, `USER`, `ASSISTANT`, `TOOL` | -| `content` | `list[ContentPartUnion]` | List of typed content parts (multimodal) | -| `channel` | `Channel \| None` | Output classification: `ANALYSIS`, `COMMENTARY`, `FINAL` | +## Message structure ---- - -## Content Parts - -Messages carry a list of typed content parts. Each part has a `content_type` discriminator: - -| Content Type | Class | Wraps | -|-------------|-------|-------| -| `text` | `TextContent` | Plain text | -| `thinking` | `ThinkingContent` | Chain-of-thought reasoning | -| `tool_call` | `ToolCallContentPart` | `ToolCall` — function invocation request | -| `tool_result` | `ToolResultContentPart` | `ToolResult` — function execution result | -| `resource` | `ResourceContentPart` | `Resource` — embedded resource with content | -| `resource_ref` | `ResourceRefContentPart` | `ResourceReference` — lightweight reference | -| `prompt_request` | `PromptRequestContentPart` | `PromptRequest` — template invocation | -| `prompt_result` | `PromptResultContentPart` | `PromptResult` — rendered template | -| `image` | `ImageContentPart` | `ImageSource` — URL or base64 image | -| `video` | `VideoContentPart` | `VideoSource` — URL or base64 video | -| `audio` | `AudioContentPart` | `AudioSource` — URL or base64 audio | -| `document` | `DocumentContentPart` | `DocumentSource` — PDF, Word, etc. | - ---- - -## MessageView +A CMF `Message` carries: -`Message.iter_views()` decomposes a message into individually addressable `MessageView` objects. Each view provides a uniform interface for policy evaluation regardless of content type: - -```python -for view in message.iter_views(): - print(f"kind={view.kind}, name={view.name}, text={view.text}") -``` - -This is the recommended way to inspect message content in plugins. Each view exposes the same fields, so your policy logic doesn't need to branch on content type. - ---- +- **role**: `system`, `developer`, `user`, `assistant`, or `tool`. +- **content**: a list of typed parts: text, thinking, tool call, tool result, resource, prompt request, image, video, audio, document. +- **channel**: optional routing such as `analysis`, `commentary`, or `final`. -## CMF Hook Types +Because content is a list of typed parts rather than a flat string, policy can target precisely: scan only `tool_call` arguments, redact a field inside a `tool_result`, or check a `text` part for injection, without disturbing the rest. -CMF hooks parallel the typed hooks but accept `MessagePayload` instead of per-type payloads: +## Views -| CMF Hook | Fires at | Parallel to | -|----------|----------|-------------| -| `cmf.tool_pre_invoke` | Before tool execution | `tool_pre_invoke` | -| `cmf.tool_post_invoke` | After tool execution | `tool_post_invoke` | -| `cmf.llm_input` | Before model/LLM call | — | -| `cmf.llm_output` | After model/LLM call | — | -| `cmf.prompt_pre_fetch` | Before prompt fetch | `prompt_pre_fetch` | -| `cmf.prompt_post_fetch` | After prompt fetch | `prompt_post_fetch` | -| `cmf.resource_pre_fetch` | Before resource fetch | `resource_pre_fetch` | -| `cmf.resource_post_fetch` | After resource fetch | `resource_post_fetch` | +A `MessageView` is a flattened projection of a message for uniform evaluation: each view has a kind (`text`, `tool_call`, `tool_result`, and so on), an optional name, and the text or structured payload. Plugins and APL field pipelines operate over views, which is why one policy expression works across content types. -The gateway fires both the typed hook and the CMF hook at each interception point. You can use either or both. +## CMF hooks ---- - -## MessagePayload - -The payload for all CMF hooks: - -```python -from cpex.framework.hooks.message import MessagePayload, MessageHookType - -payload = MessagePayload(message=msg, hook=MessageHookType.LLM_INPUT) -payload.message # the CMF Message -payload.hook # where in the pipeline this evaluation is happening -``` - -The `hook` field tells your plugin *where* the evaluation is happening, so you can apply different policies at different stages if needed. - ---- +CMF operations run at CMF hooks, which parallel the typed hooks but carry a `Message`: -## Migration Path +| Hook | Fires | +|------|-------| +| `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.resource_pre_fetch` / `cmf.resource_post_fetch` | around a resource fetch | -You can migrate from typed hooks to CMF incrementally: +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. -1. **Typed plugins** register for `tool_pre_invoke` and receive `ToolPreInvokePayload` -2. **CMF plugins** register for `cmf.tool_pre_invoke` and receive `MessagePayload` -3. Both fire at the same interception point — no conflict +## How it connects to policy -Start with CMF for new cross-cutting policies (content guardrails, PII scanning) where the unified interface saves duplication. Keep typed hooks for domain-specific logic that benefits from the typed payload fields. +CMF is the "what you evaluate" layer (see [Vision]({{< relref "/docs/vision" >}})). Identity, security labels, and delegation context ride alongside the message as typed extensions ([Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})), and APL reads all of it through one attribute bag. The message gives policy the content; the extensions give it the context; APL decides. diff --git a/docs/content/docs/configuration.md b/docs/content/docs/configuration.md index dec56be6..a8bfb031 100644 --- a/docs/content/docs/configuration.md +++ b/docs/content/docs/configuration.md @@ -1,222 +1,105 @@ --- title: "Configuration" -weight: 90 +weight: 80 --- -# Configuration Reference +# Configuration -Plugins are configured in a YAML file. You pass the file path when creating the `PluginManager`: +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. -```python -manager = PluginManager("plugins/config.yaml") -``` - -Or set it via environment variable: +## Shape -```bash -export PLUGINS_CONFIG_FILE=plugins/config.yaml -export PLUGINS_ENABLED=true +```yaml +plugins: # the plugins available to policy, by kind + - name: ... + kind: ... + hooks: [...] + capabilities: [...] + config: { ... } + +global: # cross-cutting resolvers and stores + pdp: + - kind: ... + session_store: + kind: ... + +routes: # APL policy, keyed by operation + : + args: { ... } + policy: [ ... ] + result: { ... } + post_policy: [ ... ] ``` ---- +## Plugins -## YAML Structure +Each plugin entry declares how it is identified, where it runs, and what it may see: -```yaml -plugin_dirs: - - ./plugins +| Field | Meaning | +|-------|---------| +| `name` | Instance name, referenced from APL (`plugin(name)`, `delegate(name, ...)`). | +| `kind` | Which plugin implementation (for example `identity/jwt`, `audit/logger`). | +| `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" >}})). | +| `config` | Plugin-specific settings. | +```yaml plugins: - - name: content_filter - kind: my_app.plugins.ContentFilterPlugin - version: "1.0.0" - description: "Blocks prohibited content in tool arguments" - author: "platform-team" - hooks: - - tool_pre_invoke - tags: - - security - - content - mode: sequential - on_error: fail - priority: 10 - conditions: - - server_ids: [prod-gateway] - tenant_ids: [tenant-a, tenant-b] + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] config: - blocked_patterns: - - "DROP TABLE" - - "rm -rf" + 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" + + - name: audit-log + kind: audit/logger + hooks: [cmf.tool_pre_invoke] + priority: 90 + capabilities: [read_subject, read_client, read_delegation] ``` ---- - -## Plugin Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `name` | `str` | *required* | Unique plugin identifier | -| `kind` | `str` | *required* | Fully qualified class path (e.g., `my_app.plugins.MyPlugin`), `"external"` for remote plugins, or `"isolated_venv"` for venv-isolated plugins | -| `version` | `str` | — | Semantic version | -| `description` | `str` | — | Human-readable description | -| `author` | `str` | — | Plugin author | -| `hooks` | `list[str]` | `[]` | Hook types this plugin handles | -| `tags` | `list[str]` | `[]` | Searchable tags | -| `mode` | `str` | `sequential` | Execution mode — see [Execution Modes]({{< relref "/docs/execution-modes" >}}) | -| `on_error` | `str` | `fail` | Error behavior: `fail`, `ignore`, `disable` | -| `priority` | `int` | `100` | Execution order within mode (lower = higher priority) | -| `conditions` | `list` | `[]` | When the plugin should execute | -| `capabilities` | `list[str]` | `[]` | Declared capabilities for [extension access]({{< relref "/docs/extensions" >}}) | -| `config` | `dict` | — | Plugin-specific settings passed to the constructor | -| `max_content_size` | `int` | `10000000` | Maximum payload size in bytes | -| `mcp` | `object` | — | MCP client config (for [external plugins]({{< relref "/docs/external-plugins" >}})) | -| `grpc` | `object` | — | gRPC client config (for external plugins) | -| `unix_socket` | `object` | — | Unix socket client config (for external plugins) | - ---- - -## Plugin Directories +## Global -`plugin_dirs` lists directories that CPEX adds to the Python path for plugin discovery. Use this when your plugin classes live outside the main application package: +`global.pdp` registers PDP resolvers; `global.session_store` selects where taint labels live (absent it, the in-process memory store is used). ```yaml -plugin_dirs: - - ./plugins - - ./vendor/plugins +global: + 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 ``` ---- - -## Conditions +## Routes -Conditions restrict when a plugin executes. If conditions are set and none match, the plugin is skipped for that invocation. +Routes carry the APL policy. The map-keyed form (keyed by route name) is the canonical form for configs loaded into the runtime: ```yaml -conditions: - - server_ids: [prod-gateway, staging-gateway] - tenant_ids: [tenant-a] - - tools: [web_search, code_exec] -``` - -Available condition fields: - -| Field | Type | Description | -|-------|------|-------------| -| `server_ids` | `set[str]` | Match specific server IDs | -| `tenant_ids` | `set[str]` | Match specific tenant IDs | -| `tools` | `set[str]` | Match specific tool names | -| `prompts` | `set[str]` | Match specific prompt names | -| `resources` | `set[str]` | Match specific resource URIs | -| `agents` | `set[str]` | Match specific agent IDs | -| `user_patterns` | `list[str]` | Match user patterns | -| `content_types` | `list[str]` | Match content types | - -Multiple conditions are OR'd — the plugin runs if **any** condition matches. Fields within a single condition are AND'd. - ---- - -## Plugin-Specific Config - -The `config` dict is passed to your plugin's constructor via `PluginConfig.config`. You access it in `__init__`: - -```python -from pydantic import BaseModel -from cpex.framework import Plugin, PluginConfig - - -class FilterConfig(BaseModel): - blocked_patterns: list[str] - case_sensitive: bool = False - - -class ContentFilterPlugin(Plugin): - def __init__(self, config: PluginConfig): - super().__init__(config) - self._filter = FilterConfig.model_validate(config.config) +routes: + get_compensation: + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" + - "plugin(audit-log)" + result: + ssn: "str | redact(!perm.view_ssn)" ``` -Validating with a Pydantic model gives you type safety and clear error messages if the YAML config is malformed. - ---- - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `PLUGINS_ENABLED` | `false` | Enable the plugin framework | -| `PLUGINS_CONFIG_FILE` | `plugins/config.yaml` | Path to plugin configuration | -| `PLUGINS_PLUGIN_TIMEOUT` | `30` | Max execution time per plugin (seconds) | -| `PLUGINS_EXECUTION_POOL` | — | Max concurrent tasks (semaphore limit) | -| `PLUGINS_DEFAULT_HOOK_POLICY` | `allow` | Default policy for hooks without explicit rules: `allow` or `deny` | - ---- - -## Legacy Mode Migration - -If you are upgrading from an older version of CPEX, these mode names are automatically migrated: - -| Legacy Mode | Current Equivalent | -|-------------|-------------------| -| `enforce` | `sequential` | -| `permissive` | `transform` | -| `enforce_ignore_error` | `sequential` + `on_error: ignore` | - ---- - -## Complete Example - -```yaml -plugin_dirs: - - ./plugins - -plugins: - # Policy enforcement — blocks dangerous tools - - name: tool_policy - kind: plugins.security.ToolPolicyPlugin - version: "1.0.0" - hooks: - - tool_pre_invoke - mode: sequential - priority: 10 - on_error: fail - conditions: - - server_ids: [prod-gateway] - config: - blocked_tools: - - admin_delete - - raw_sql_exec - - # PII redaction — cleans arguments before tools run - - name: pii_redactor - kind: plugins.privacy.PIIRedactionPlugin - version: "1.0.0" - hooks: - - tool_pre_invoke - - tool_post_invoke - mode: transform - priority: 20 - on_error: ignore - - # Audit logging — async, never blocks - - name: audit_logger - kind: plugins.observability.AuditLogPlugin - version: "1.0.0" - hooks: - - tool_pre_invoke - - tool_post_invoke - - prompt_pre_fetch - mode: fire_and_forget - priority: 100 - on_error: ignore - - # Experimental policy — dry-run only - - name: new_content_policy - kind: plugins.experimental.ContentPolicyV2 - version: "0.1.0" - hooks: - - tool_pre_invoke - mode: audit - priority: 15 -``` +Deployment integrations that wrap CPEX (a gateway or sidecar) often express routes as a list of `- tool:` entries instead; that form carries the same `policy`/`args`/`result` blocks. See [Deployment]({{< relref "/docs/deployment" >}}) for that variant, and [APL]({{< relref "/docs/apl" >}}) for the policy syntax itself. -This pipeline runs in order: `tool_policy` (sequential, blocks) → `pii_redactor` (transform, modifies) → `new_content_policy` (audit, observes) → `audit_logger` (fire_and_forget, logs in background). +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. diff --git a/docs/content/docs/deployment.md b/docs/content/docs/deployment.md new file mode 100644 index 00000000..e44d9acf --- /dev/null +++ b/docs/content/docs/deployment.md @@ -0,0 +1,51 @@ +--- +title: "Deployment" +weight: 90 +--- + +# Deployment + +CPEX is the enforcement point, but where that point sits is your choice. The same APL policy enforces whether CPEX runs as a gateway in front of a tool server, as an egress sidecar beside an agent, or inside an agent framework. You move the boundary; the policy does not change. + +## The same policy, any enforcement point + +Take the `get_compensation` route. It is identical whether CPEX fronts the backend, guards the agent's egress, or runs inside the agent runtime: + +```yaml +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" + result: + ssn: "str | redact(!perm.view_ssn)" +``` + +![CPEX enforcing the same policy at three settings: as a gateway in front of the tool server, as an egress sidecar beside the agent, and in-framework inside the agent runtime](/cpex/images/deployment.png) + +As a **gateway**, CPEX sits in front of the tool server and enforces on inbound calls: every request to the backend passes through it. As an **egress sidecar**, CPEX sits beside the agent and enforces on the agent's outbound calls: the agent's tool invocations leave through the sidecar's proxy. **In-framework**, CPEX runs inside the agent runtime and enforces operations as the runtime issues them. The enforcement point moves; the route above runs unchanged in all three. + +## Route forms + +A deployment integration usually expresses routes as a list of `- tool:` entries, with the `policy`, `args`, and `result` blocks directly under each. This is the same policy you would write in the map-keyed form (see [Configuration]({{< relref "/docs/configuration" >}})); the wrapping differs, the rules do not. Pick one form per deployment and keep it consistent. + +## Placement guidance + +| Placement | Controls | Use when | +|-----------|----------|----------| +| Gateway (inbound) | every call reaching a backend, from any client | you own the tool server and want one chokepoint in front of it | +| Egress sidecar (outbound) | every call an agent makes, to any backend | you own the agent and want to guard what it can reach | +| In-framework | operations as the agent runtime issues them | you control the runtime and want enforcement inline | + +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. + +## 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. + +## What to read next + +- [Configuration]({{< relref "/docs/configuration" >}}): the full config structure for a deployment. +- [Patterns]({{< relref "/docs/patterns" >}}): production patterns for rollout and layered enforcement. +- [Identity]({{< relref "/docs/apl/identity" >}}) and [Delegation]({{< relref "/docs/apl/delegation" >}}): wiring IdP verification and token exchange in a real stack. diff --git a/docs/content/docs/extensions.md b/docs/content/docs/extensions.md index 15f4e627..106a5e32 100644 --- a/docs/content/docs/extensions.md +++ b/docs/content/docs/extensions.md @@ -1,199 +1,102 @@ --- -title: "Extensions & Capabilities" -weight: 60 +title: "Extensions & Capability-Gating" +weight: 50 --- -# Extensions & Capabilities +# Extensions and Capability-Gating -Extensions carry typed contextual metadata — identity, security labels, HTTP headers, delegation chains — through the plugin pipeline. The capability system controls which plugins can see and modify which extension slots. +Alongside the message, every operation carries typed **extensions**: the contextual state policy reasons about. Identity is an extension. So are security labels, the delegation chain, request headers, agent session context, and more. Each extension is bridged into the flat attribute bag APL reads, under a well-known namespace. **Capability-gating** controls which plugins may read or write each one. ---- - -## The Extensions Container - -`Extensions` is a frozen Pydantic model that attaches to payloads flowing through the pipeline. Each field is an optional typed slot: - -```python -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.request import RequestExtension -from cpex.framework.extensions.security import SecurityExtension - -ext = Extensions( - request=RequestExtension(environment="production", request_id="req-001"), - security=SecurityExtension(labels=frozenset({"pii", "confidential"})), -) - -ext.request.environment # "production" -ext.security.labels # frozenset({"pii", "confidential"}) -ext.http # None — not populated -``` - -Extensions are frozen. To modify, use `model_copy(update={...})`: - -```python -updated = ext.model_copy(update={"custom": {"trace_id": "abc-123"}}) -``` +This is a supporting concern, not the headline. You rarely configure it directly. It matters because it is what makes least privilege real for the plugins that execute policy effects, and because the namespaces below are the exact keys an APL predicate or plugin can read. ---- +## The extensions -## Extension Slots - -| Slot | Type | Description | Access | -|------|------|-------------|--------| -| `request` | `RequestExtension` | Environment, request ID, timestamp, tracing | Unrestricted | -| `agent` | `AgentExtension` | Session tracking, multi-agent lineage | `read_agent` | -| `http` | `HttpExtension` | HTTP headers | `read_headers` / `write_headers` | -| `security` | `SecurityExtension` | Labels, classification, subject identity | Mixed (see below) | -| `delegation` | `DelegationExtension` | Token delegation chain | `read_delegation` / `append_delegation` | -| `mcp` | `MCPExtension` | Tool, resource, or prompt metadata | Unrestricted | -| `completion` | `CompletionExtension` | Stop reason, token usage, model, latency | Unrestricted | -| `provenance` | `ProvenanceExtension` | Source, message ID, parent ID | Unrestricted | -| `llm` | `LLMExtension` | Model identity and capabilities | Unrestricted | -| `framework` | `FrameworkExtension` | Agentic framework context | Unrestricted | -| `meta` | `MetaExtension` | Host-provided operational metadata | Unrestricted | -| `custom` | `dict[str, Any]` | Free-form plugin data | Unrestricted | - -**Unrestricted** slots are visible to all plugins. **Capability-gated** slots require a declared capability. +Each extension flattens into bag attributes under its namespace, gated by a read capability. A prefix ending in `.` matches any key beneath it (`role.` matches `role.hr`); a bare name is an exact key. ---- +| 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 (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` | +| 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 and response headers (lowercased) | `http.request_headers.*`, `http.response_headers.*` | `read_headers`, `write_headers` | +| LLM | model id, provider, capabilities | `llm.*` | `read_llm` | +| 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` | -## Mutability Tiers - -Each extension slot has a mutability tier that the pipeline enforces: - -| Tier | Rule | Example | -|------|------|---------| -| **Immutable** | Set once, never changed. Pipeline rejects any delta. | `request`, `provenance`, `agent` | -| **Monotonic** | Can only grow — elements can be added, never removed. Pipeline validates `before ⊆ after`. | `security.labels`, `delegation.chain` | -| **Mutable** | Freely modifiable via copy-on-write. | `custom` | - ---- +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. ## Capabilities -Capabilities are declared in the plugin's YAML config and control what a plugin can access: +A plugin declares the capabilities it needs. CPEX filters the extensions before handing them to the plugin, so a plugin sees only what it declared. The default is no access; capabilities are additive grants. ```yaml plugins: - - name: header_injector - kind: my_app.HeaderInjectorPlugin - hooks: - - tool_pre_invoke - mode: sequential + - name: audit-log + kind: audit/logger + hooks: [cmf.tool_pre_invoke] capabilities: - - read_headers - - write_headers + - read_subject + - read_client + - read_delegation ``` -Available capabilities: +### Read capabilities and the bag keys they unlock + +| 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_claims` | `claim.*` (plus baseline) | +| `read_client` | `client.*` | +| `read_workload` | `workload.*`, `caller_workload.*` | +| `read_delegation` | `delegation.*`, `delegated` | +| `read_agent` | `agent.*` | +| `read_meta` | `meta.*` | +| `read_request` | `request.*` | +| `read_headers` | `http.request_headers.*`, `http.response_headers.*` | +| `read_llm` | `llm.*` | +| `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_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. + +### Write capabilities + +Three capabilities grant write tokens rather than read access: | Capability | Grants | |-----------|--------| -| `read_subject` | Read subject ID and type | -| `read_roles` | Read subject roles (implies `read_subject`) | -| `read_teams` | Read subject teams (implies `read_subject`) | -| `read_claims` | Read subject claims (implies `read_subject`) | -| `read_permissions` | Read subject permissions (implies `read_subject`) | -| `read_agent` | Read agent extension | -| `read_headers` | Read HTTP headers | -| `write_headers` | Read + write HTTP headers | -| `read_labels` | Read security labels | -| `append_labels` | Read + append security labels (monotonic) | -| `read_delegation` | Read delegation chain | -| `append_delegation` | Read + append delegation chain (monotonic) | - -Write capabilities imply their corresponding read capability. A plugin with `write_headers` can also read headers. - ---- +| `append_labels` | add a taint label (monotonic; cannot remove) | +| `append_delegation` | extend the delegation chain (monotonic) | +| `write_headers` | rewrite request and response headers (implies `read_headers`) | -## How It Works - -The framework applies two filters around every plugin execution: - -1. **Before** — `filter_extensions()` builds a new `Extensions` containing only the slots the plugin has access to. Slots the plugin can't see are `None`. -2. **After** — `merge_extensions()` accepts back only the changes the plugin was authorized to make. Immutable slots are ignored. Monotonic slots are validated for growth. Unauthorized writes are silently discarded. - -This means plugins can't even *see* data they lack capabilities for, and they can't sneak in unauthorized changes. - ---- +## Mutability tiers -## Accepting Extensions in a Hook +Extensions differ in how they may change during a request, and the runtime enforces the tier: -Add a third parameter to your hook signature: +- **Immutable**: fixed once resolved. The verified subject identity, client, workload, agent, meta, request, LLM, MCP, completion, provenance, and framework extensions. +- **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. -```python -from cpex.framework import hook, Plugin, PluginContext, PluginResult, ToolPreInvokePayload, ToolPreInvokeResult -from cpex.framework.extensions.extensions import Extensions - - -class HeaderInspectorPlugin(Plugin): - @hook("tool_pre_invoke") - async def inspect_headers( - self, - payload: ToolPreInvokePayload, - context: PluginContext, - extensions: Extensions, - ) -> ToolPreInvokeResult: - if extensions.http: - auth = extensions.http.headers.get("authorization", "none") - context.set_state("auth_method", auth.split()[0] if " " in auth else auth) - return ToolPreInvokeResult(continue_processing=True) -``` - -The framework detects the 3-parameter signature automatically and passes the capability-filtered extensions. - ---- - -## Returning Modified Extensions - -To modify extensions, return `modified_extensions` in the result: - -```python -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.http import HttpExtension - - -class TokenDelegationPlugin(Plugin): - @hook("tool_pre_invoke") - async def delegate_token( - self, - payload: ToolPreInvokePayload, - context: PluginContext, - extensions: Extensions, - ) -> ToolPreInvokeResult: - delegated_token = await self._exchange_token(extensions) - - updated_http = HttpExtension( - headers={**(extensions.http.headers if extensions.http else {}), - "authorization": f"Bearer {delegated_token}"}, - ) - updated_ext = extensions.model_copy(update={"http": updated_http}) - - return ToolPreInvokeResult( - continue_processing=True, - modified_extensions=updated_ext, - ) -``` - -The manager merges only the fields the plugin is authorized to write. In this case, the plugin needs `write_headers` in its capabilities. - ---- - -## Security Sub-Field Gating - -The `security` extension has granular sub-field access control. A plugin with `read_roles` can see `security.subject.roles` but not `security.subject.claims`: - -```yaml -capabilities: - - read_roles - - read_labels -``` +So a plugin cannot clear a taint label or rewrite a verified identity even if it holds the corresponding read capability. This is what keeps the state APL depends on trustworthy: the model is untrusted, and so is any plugin beyond the context and mutations it was explicitly granted. -This plugin sees: -- `security.subject.id` and `security.subject.type` (implied by `read_roles`) -- `security.subject.roles` (granted by `read_roles`) -- `security.labels` (granted by `read_labels`) -- `security.objects`, `security.data`, `security.classification` (always unrestricted) +## How it connects to policy -It does **not** see: -- `security.subject.teams`, `security.subject.claims`, `security.subject.permissions` +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. diff --git a/docs/content/docs/overview.md b/docs/content/docs/overview.md index 43de4e60..b7e783f3 100644 --- a/docs/content/docs/overview.md +++ b/docs/content/docs/overview.md @@ -3,43 +3,67 @@ title: "Overview" weight: 10 --- -# Overview +# How CPEX Works -## Why CPEX? +## A running scenario -AI systems interact with tools, APIs, data sources, and other agents. Adding guardrails, observability, or policy checks typically means embedding that logic directly into application code — leading to duplication, tight coupling, and drift. +Picture one agent serving several people. It answers questions by calling tools (an HR records service, a code repository, an email sender), invoking other agents over A2A, running inference, and fetching prompts and resources. The backends are shared. The callers are not: an HR analyst, an engineer, and a support rep each drive the same agent with different identities and different entitlements. -CPEX introduces **standardized interception hooks** between your application and its operations. Plugins attach to these hooks and run automatically, keeping enforcement logic separate from business logic. +The agent's LLM decides which operation to run. It is untrusted. CPEX sits between it and every capability, and decides what actually happens. -## How It Works +![CPEX mediates every operation an untrusted LLM triggers, evaluating APL policy against identity, delegation, taint, and audit state the model cannot forge](/cpex/images/cpex_overview.png) -Your application defines **hooks** — named interception points before and after critical operations. Plugins register against these hooks and execute automatically when triggered. The plugin manager handles registration, ordering, execution, timeouts, and error isolation. +For each operation, CPEX resolves the caller's identity, evaluates the APL policy attached to that operation, and applies the resulting effects before anything reaches the backend. The same four phases run every time: validate arguments, evaluate policy, transform the result, run post-policy checks. -```goat - .---. .----. .-------. .------. .---. - | App +----->| Hook +----->| Manager +------>| Result +----->| App | - '---' '----' '---+---' '------' '---' - | - .-----------+-----------. - | | | - v v v - .--------. .--------. .--------. - | Plugin A | | Plugin B | | Plugin C | - '--------' '--------' '--------' +## Same request, different data + +The clearest demonstration is redaction on the wire. Three callers issue the identical request, `get_compensation`. The backend returns the same record. What each caller receives differs, because policy decides per identity. + +```yaml +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + result: + ssn: "str | redact(!perm.view_ssn)" +``` + +- An HR analyst with the `view_ssn` permission gets the full record. +- An HR analyst without `view_ssn` gets the same record with the SSN redacted before it leaves CPEX. The backend never sees the difference; the redaction happens at the boundary. +- An engineer is denied at `require(role.hr)`. The call never reaches the backend. + +```mermaid +flowchart LR + REQ["get_compensation
(identical request)"] --> CPEX{{"APL policy"}} + CPEX -->|"HR + view_ssn"| FULL["full record"] + CPEX -->|"HR, no view_ssn"| RED["record, SSN redacted"] + CPEX -->|"not HR"| DENY["denied"] ``` -When a hook fires, the plugin manager dispatches the payload to every registered plugin in priority order. Each plugin can: +No application code changed between the three outcomes. The policy did. + +## State that follows the session + +Some controls depend on what already happened. When a caller reads compensation data, the policy above marks the session with `taint(secret, session)`. A later operation can refuse based on that label, even when its own payload is clean: + +```yaml +routes: + - tool: send_email + policy: + - "require(perm.email_send)" + - "security.labels contains \"secret\": deny('session touched secret data', 'session_tainted')" +``` -- **Allow** execution to continue unchanged -- **Modify** the payload (e.g., redact sensitive data, inject defaults) -- **Block** execution with a violation (e.g., deny a prohibited tool call) +An email with no sensitive content in its body is still blocked if the session previously read secret data. This is a write-down control, and the LLM cannot route around it because the taint lives in CPEX, not in the conversation. See [Session Tainting]({{< relref "/docs/apl/tainting" >}}) for how labels propagate and persist. -You get a deterministic pipeline with no surprises. +## Where the boundary sits -## Built-in Hooks +CPEX is the boundary, but the boundary can be placed in more than one spot. The policy does not change; the enforcement point does. -CPEX ships with hooks for common AI operations — tools, prompts, resources, agents, HTTP requests, identity resolution, and a unified Common Message Format for cross-cutting policy evaluation. You can also [register your own hooks]({{< relref "/docs/hooks#custom-hooks" >}}) for any domain. +A gateway in front of a tool server controls inbound calls. A sidecar on the agent controls its outbound calls. An in-framework integration controls operations as the runtime issues them. The same APL policy enforces in all three. [Deployment]({{< relref "/docs/deployment" >}}) walks through each. -## Next Steps +## What to read next -Ready to build? The [Quick Start]({{< relref "/docs/quickstart" >}}) gets you a working plugin in five minutes. +- [APL]({{< relref "/docs/apl" >}}): the policy language, end to end. +- [Identity]({{< relref "/docs/apl/identity" >}}): how callers are resolved into the attributes policy reads. +- [Quick Start]({{< relref "/docs/quickstart" >}}): stand up CPEX and run this scenario. diff --git a/docs/content/docs/patterns.md b/docs/content/docs/patterns.md index dc92d8b6..0ced64a3 100644 --- a/docs/content/docs/patterns.md +++ b/docs/content/docs/patterns.md @@ -1,256 +1,74 @@ --- -title: "Patterns & Best Practices" -weight: 110 +title: "Patterns" +weight: 100 --- -# Patterns & Best Practices +# Patterns -Curated patterns for building production plugin pipelines. +Production patterns for writing and rolling out CPEX policy. Each is expressed in APL and builds on the concepts in the earlier pages. ---- - -## Layered Security Pipeline +## Layered enforcement -Compose modes and priorities to build defense-in-depth. Each layer has a specific responsibility: +Order effects cheapest-gate-first so expensive work only runs for requests that survive the early checks. Attribute gates, then a PDP call, then delegation: ```yaml -plugins: - # Layer 1: hard enforcement — blocks requests that violate policy - - name: token_budget - kind: security.TokenBudgetPlugin - mode: sequential - priority: 10 - hooks: [tool_pre_invoke] - - # Layer 2: content policy — blocks prohibited content - - name: content_policy - kind: security.ContentPolicyPlugin - mode: sequential - priority: 20 - hooks: [tool_pre_invoke, agent_pre_invoke] - - # Layer 3: transformation — redacts PII without blocking - - name: pii_redactor - kind: privacy.PIIRedactionPlugin - mode: transform - priority: 30 - hooks: [tool_pre_invoke, tool_post_invoke] - - # Layer 4: background logging — never blocks or slows - - name: audit_logger - kind: observability.AuditLogPlugin - mode: fire_and_forget - priority: 100 - hooks: [tool_pre_invoke, tool_post_invoke, prompt_pre_fetch] +policy: + - "require(team.engineering | team.security)" # cheap + - cedar: { action: 'Action::"read"', resource: { type: Repo, id: ${args.repo_name} } } + - "delegate(github-oauth, target: github-api, permissions: [repo:read])" # expensive, last ``` -Execution order: `token_budget` (sequential) → `content_policy` (sequential) → `pii_redactor` (transform) → `audit_logger` (fire_and_forget). Each layer can only do what its mode permits. - ---- +A deny at any layer halts the rest, so you never mint a token for a request a later layer would reject. -## Graceful Policy Rollout with Audit Mode +## Shadow rollout with audit mode -Deploy new policies safely by starting in `audit` mode. Violations are logged but don't block traffic: +Before a new policy blocks traffic, run it in `audit` mode to observe what it would do without enforcing. An audit-mode plugin records decisions but cannot block, so you can measure a policy's deny rate against real traffic, then switch it to `sequential` once the rate is what you expect. ```yaml - - name: new_content_policy_v2 - kind: experimental.ContentPolicyV2 - mode: audit # observe only — no blocking, no modifications - priority: 15 - hooks: [tool_pre_invoke] -``` - -Monitor your logs for violations. When you're confident the policy is tuned correctly, promote to `sequential`: - -```yaml - mode: sequential # now enforcing -``` - -This gives you zero-risk rollout for any new policy. - ---- - -## Input/Output Guardrails - -Apply the same `transform` plugin to both pre- and post-invoke hooks to sanitize inputs and outputs: - -```python -import re - -from cpex.framework import Plugin, PluginConfig, PluginContext, ToolPreInvokePayload, ToolPreInvokeResult, ToolPostInvokePayload, ToolPostInvokeResult - -CREDIT_CARD = re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b") - - -class PIIGuardrailPlugin(Plugin): - async def tool_pre_invoke( - self, payload: ToolPreInvokePayload, context: PluginContext - ) -> ToolPreInvokeResult: - if not payload.args: - return ToolPreInvokeResult(continue_processing=True) - cleaned = { - k: CREDIT_CARD.sub("[CARD-REDACTED]", v) if isinstance(v, str) else v - for k, v in payload.args.items() - } - return ToolPreInvokeResult( - continue_processing=True, - modified_payload=payload.model_copy(update={"args": cleaned}), - ) - - async def tool_post_invoke( - self, payload: ToolPostInvokePayload, context: PluginContext - ) -> ToolPostInvokeResult: - if isinstance(payload.result, str): - cleaned = CREDIT_CARD.sub("[CARD-REDACTED]", payload.result) - return ToolPostInvokeResult( - continue_processing=True, - modified_payload=payload.model_copy(update={"result": cleaned}), - ) - return ToolPostInvokeResult(continue_processing=True) -``` - -Configure with `mode: transform` so the plugin can modify payloads but never accidentally block the pipeline. - ---- - -## Cross-Hook State - -Use `PluginContext.state` to pass data between hooks within the same request lifecycle. The context persists across pre- and post-invoke hooks for the same request: - -```python -import time - -from cpex.framework import Plugin, PluginContext, ToolPreInvokePayload, ToolPreInvokeResult, ToolPostInvokePayload, ToolPostInvokeResult - - -class LatencyTrackerPlugin(Plugin): - async def tool_pre_invoke( - self, payload: ToolPreInvokePayload, context: PluginContext - ) -> ToolPreInvokeResult: - context.set_state("start_time", time.monotonic()) - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke( - self, payload: ToolPostInvokePayload, context: PluginContext - ) -> ToolPostInvokeResult: - start = context.get_state("start_time") - if start: - elapsed_ms = (time.monotonic() - start) * 1000 - context.set_state("tool_latency_ms", elapsed_ms) - return ToolPostInvokeResult(continue_processing=True) +plugins: + - name: new-policy-check + kind: validator/pii-scan + mode: audit # observe only; flip to sequential to enforce + on_error: ignore ``` ---- - -## Config-Driven Deny/Allow Lists - -Drive plugin behavior from YAML config — no code changes needed to update the rules: - -```python -from cpex.framework import Plugin, PluginConfig, PluginContext, PluginViolation, ToolPreInvokePayload, ToolPreInvokeResult - +## Input and output guardrails -class ToolAllowListPlugin(Plugin): - def __init__(self, config: PluginConfig): - super().__init__(config) - self._allowed = set((config.config or {}).get("allowed_tools", [])) - - async def tool_pre_invoke( - self, payload: ToolPreInvokePayload, context: PluginContext - ) -> ToolPreInvokeResult: - if self._allowed and payload.name not in self._allowed: - return ToolPreInvokeResult( - continue_processing=False, - violation=PluginViolation( - reason=f"Tool '{payload.name}' not in allow list", - description="Only explicitly allowed tools may be invoked.", - code="TOOL_NOT_ALLOWED", - ), - ) - return ToolPreInvokeResult(continue_processing=True) -``` +Validate and transform on the way in with `args`, redact on the way out with `result`. The two phases bracket the operation: ```yaml - - name: tool_allowlist - kind: security.ToolAllowListPlugin - mode: sequential - priority: 5 - hooks: [tool_pre_invoke] - config: - allowed_tools: - - web_search - - calculator - - file_read -``` - ---- - -## Plugin-Specific Config with Pydantic - -Validate your plugin's `config` dict at init time using a Pydantic model. This gives you type safety, default values, and clear error messages: - -```python -from pydantic import BaseModel -from cpex.framework import Plugin, PluginConfig - - -class RateLimitConfig(BaseModel): - requests_per_minute: int = 60 - burst_size: int = 10 - scope: str = "user" # "user" or "global" - - -class RateLimitPlugin(Plugin): - def __init__(self, config: PluginConfig): - super().__init__(config) - self._settings = RateLimitConfig.model_validate(config.config or {}) +routes: + get_employee: + args: + employee_id: "str | regex(\"^[0-9]{6}$\")" # reject malformed input + result: + ssn: "str | redact(!perm.view_ssn)" # redact output by permission ``` -If the YAML provides an invalid value (e.g., `requests_per_minute: "not_a_number"`), Pydantic raises a validation error at plugin initialization rather than at runtime. - ---- - -## Idempotent Initialize and Shutdown - -Make `initialize()` and `shutdown()` safe to call multiple times: +## Cross-request information flow -```python -class MyPlugin(Plugin): - def __init__(self, config): - super().__init__(config) - self._client = None +Taint a session when it touches sensitive data, then gate later operations on the label. The control spans requests and the model cannot route around it (see [Session Tainting]({{< relref "/docs/apl/tainting" >}})): - async def initialize(self): - if self._client is None: - self._client = await create_client() - - async def shutdown(self): - if self._client is not None: - await self._client.close() - self._client = None +```yaml +routes: + get_compensation: + policy: [ "require(role.hr)", "taint(secret, session)" ] + send_email: + policy: + - "require(perm.email_send)" + - "security.labels contains \"secret\": deny('write-down blocked', 'session_tainted')" ``` -The plugin manager may call these methods more than once during lifecycle transitions. Guard against double-initialization and double-cleanup. - ---- - -## Observability Stack +## Least-privilege effects -Use `fire_and_forget` plugins for telemetry that must never slow the pipeline: +Declare the narrowest capabilities each plugin needs, and scope delegated tokens to the minimum. A scanner that reads content does not get identity; a downstream token gets only the scope the operation requires, verified after the exchange: ```yaml -plugins: - - name: request_tracer - kind: observability.RequestTracerPlugin - mode: fire_and_forget - priority: 100 - hooks: [tool_pre_invoke, tool_post_invoke, prompt_pre_fetch, prompt_post_fetch] - - - name: metrics_collector - kind: observability.MetricsPlugin - mode: fire_and_forget - priority: 101 - hooks: [tool_pre_invoke, tool_post_invoke] +policy: + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "delegation.granted.permissions contains 'read_compensation': allow" # verify least privilege ``` -These plugins receive an isolated snapshot of the payload, run asynchronously in the background, and their exceptions are logged but never propagated. The main pipeline is unaffected even if a telemetry backend is down. +## 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. diff --git a/docs/content/docs/pipeline.md b/docs/content/docs/pipeline.md new file mode 100644 index 00000000..6e6cd39e --- /dev/null +++ b/docs/content/docs/pipeline.md @@ -0,0 +1,49 @@ +--- +title: "Plugins & Pipeline" +weight: 60 +--- + +# Plugins and the Execution Pipeline + +APL is the policy surface. The pipeline is what runs underneath it: the mechanism that executes a policy's effects. Most of the time you write APL and never touch the pipeline directly. You reach for it when you extend the set of effects available to policy, by adding a plugin, or when you need to understand exactly how and when effects run. + +## 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. + +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. + +## The plugin manager + +The `PluginManager` 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. + +## Execution modes + +A plugin runs in a **mode** that fixes whether it can block, whether it can modify, and how it runs relative to others. Modes run in a fixed phase order: + +``` +sequential -> transform -> audit -> concurrent -> fire_and_forget +``` + +| Mode | Execution | Can block? | Can modify? | Use | +|------|-----------|:----------:|:-----------:|-----| +| `sequential` | serial, chained | yes | yes | policy enforcement + transformation | +| `transform` | serial, chained | no | yes | redaction, rewriting | +| `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 | + +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. + +The bundled plugins (identity, delegation, PII, audit, PDPs) 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 4811d19d..1d8f5d22 100644 --- a/docs/content/docs/quickstart.md +++ b/docs/content/docs/quickstart.md @@ -3,165 +3,65 @@ title: "Quick Start" weight: 20 --- -# Your First Plugin in 5 Minutes +# Quick Start -This guide walks you through installing CPEX, writing a plugin, configuring it, and running it. +This walks through standing up CPEX as an enforcement point and running the [scenario]({{< relref "/docs/overview" >}}): the `get_employee` route that authorizes by role and redacts a field by permission. -## Install +## 1. Add CPEX ```bash -pip install cpex +cargo add cpex --features builtins ``` -## What Are Plugins? +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" >}})). -Plugins let you intercept and modify execution at well-defined points — without changing the targeted application code. +## 2. Register the runtime -You define **hooks** in your application where you want extensibility. Plugins attach to those hooks and run automatically whenever they fire. +Create a `PluginManager`, register the enabled builtin factories, and install the APL config visitor in one call: -## 1. Write a Plugin +```rust +use std::sync::Arc; +use cpex::PluginManager; -A plugin is a class that subclasses `Plugin` and implements one or more hook handlers. Here you will create a plugin that blocks specific tools by name. - -Create a file `plugins/tool_blocker.py`: - -```python -import logging - -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PluginViolation, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - -log = logging.getLogger(__name__) - - -class ToolBlockerPlugin(Plugin): - def __init__(self, config: PluginConfig): - super().__init__(config) - self._blocked = set(config.config.get("blocked_tools", [])) - - async def tool_pre_invoke( - self, payload: ToolPreInvokePayload, context: PluginContext - ) -> ToolPreInvokeResult: - if payload.name in self._blocked: - log.warning("Blocked tool: %s", payload.name) - return ToolPreInvokeResult( - continue_processing=False, - violation=PluginViolation( - reason=f"Tool '{payload.name}' is not allowed", - description="This tool has been blocked by policy.", - code="TOOL_BLOCKED", - ), - ) - return ToolPreInvokeResult(continue_processing=True) +let mgr = Arc::new(PluginManager::default()); +cpex::install_builtins(&mgr); ``` -The method name `tool_pre_invoke` matches the hook name — CPEX discovers it automatically. No decorator needed. +After this, the manager knows every builtin `kind` your features enabled, and APL routes can reference them. -## 2. Configure the Plugin +## 3. Write the policy -Create `plugins/config.yaml`: +APL configs loaded into the manager use the map-keyed `routes:` form, keyed by route name. This route authorizes by role and redacts on the wire by permission: ```yaml -plugin_dirs: - - ./plugins - -plugins: - - name: tool_blocker - kind: plugins.tool_blocker.ToolBlockerPlugin - version: "1.0.0" - hooks: - - tool_pre_invoke - mode: sequential - priority: 10 - config: - blocked_tools: - - dangerous_tool - - admin_delete -``` - -Key fields: - -- **`kind`** — fully qualified class path to your plugin -- **`hooks`** — which hook points this plugin handles -- **`mode`** — execution mode (`sequential` lets you block *and* modify) -- **`priority`** — lower numbers run first (10 runs before 100) -- **`config`** — plugin-specific settings passed to your constructor - -## 3. Run the Pipeline - -```python -import asyncio -from cpex.framework import ( - GlobalContext, - PluginManager, - ToolPreInvokePayload, -) - - -async def main(): - manager = PluginManager("plugins/config.yaml") - await manager.initialize() - - payload = ToolPreInvokePayload(name="dangerous_tool", args={"target": "production"}) - context = GlobalContext(request_id="req-001", user="alice") - - result, _ = await manager.invoke_hook("tool_pre_invoke", payload, context) - - if result.continue_processing: - print("Allowed — proceed with tool call") - else: - print(f"Blocked: {result.violation.reason}") - # Output: Blocked: Tool 'dangerous_tool' is not allowed - - await manager.shutdown() - - -asyncio.run(main()) -``` - -That's it. Three files — a plugin, a config, and a driver — and you have a working enforcement pipeline. - ---- - -## Alternative: The `@hook` Decorator - -If you want the method name to differ from the hook name, use the `@hook` decorator: - -```python -from cpex.framework import hook, Plugin, PluginContext, ToolPreInvokePayload, ToolPreInvokeResult - - -class ToolBlockerPlugin(Plugin): - @hook("tool_pre_invoke") - async def check_tool_access( - self, payload: ToolPreInvokePayload, context: PluginContext - ) -> ToolPreInvokeResult: - # same logic as before - return ToolPreInvokeResult(continue_processing=True) +routes: + get_employee: + args: + employee_id: "str" + policy: + - "require(authenticated)" + - "require(role.hr)" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" ``` -The decorator is also useful when a single plugin handles multiple hooks — you can give each method a descriptive name without worrying about naming collisions. - ---- +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. -## Using `get_plugin_manager` +## 4. Run it -For applications that configure CPEX through environment variables (`PLUGINS_ENABLED=true`, `PLUGINS_CONFIG_FILE=plugins/config.yaml`), you can use the singleton helper instead of constructing the manager directly: +Load the config into the manager and dispatch operations through it. The four phases run automatically: `args` validates `employee_id`, `policy` authorizes, `result` redacts. See [`crates/cpex-core/examples`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core/examples) for runnable end-to-end programs that load a config and invoke a route. -```python -from cpex.framework import get_plugin_manager +The outcome matches the scenario: -manager = get_plugin_manager() -if manager: - await manager.initialize() -``` +- An HR caller with `view_ssn` receives the full record. +- 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. -## Next Steps +## Next -Now that you have a working plugin, learn how hooks work in detail: [Hooks]({{< relref "/docs/hooks" >}}). +- [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. +- [Delegation]({{< relref "/docs/apl/delegation" >}}): minting scoped downstream credentials. diff --git a/docs/content/docs/reference.md b/docs/content/docs/reference.md new file mode 100644 index 00000000..afccb9cf --- /dev/null +++ b/docs/content/docs/reference.md @@ -0,0 +1,32 @@ +--- +title: "Reference" +weight: 120 +--- + +# Crate Reference + +CPEX is a Cargo workspace of focused crates. Most hosts depend on `cpex` (the facade); plugin authors depend on `cpex-sdk`. + +| 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. | + +Generated API docs are on [docs.rs/cpex](https://docs.rs/cpex). + +## 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. + +## 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" >}}).) diff --git a/docs/content/docs/testing.md b/docs/content/docs/testing.md index fb44ee4d..2802f297 100644 --- a/docs/content/docs/testing.md +++ b/docs/content/docs/testing.md @@ -1,233 +1,40 @@ --- -title: "Testing Plugins" -weight: 120 +title: "Testing" +weight: 110 --- -# Testing Plugins +# Testing Policy -Plugins are plain async classes — you can test them directly without the full framework. For integration testing, use `PluginManager` with a test configuration. +Policy is code, and it deserves tests. The behaviors worth covering are the ones the scenario demonstrates: a route allows the right callers, denies the wrong ones, and redacts the right fields. Because APL is declarative and evaluated by `apl-core`, you can test a route by evaluating it against fixture identities and asserting the outcome, without standing up a live backend. ---- - -## Unit Testing - -Call hook methods directly with constructed payloads and contexts. No framework overhead needed. - -```python -import pytest - -from cpex.framework import ( - GlobalContext, - PluginConfig, - PluginContext, - ToolPreInvokePayload, -) - - -@pytest.mark.asyncio -async def test_tool_blocker_blocks_dangerous_tool(): - config = PluginConfig( - name="test_blocker", - kind="plugins.tool_blocker.ToolBlockerPlugin", - version="1.0.0", - hooks=["tool_pre_invoke"], - config={"blocked_tools": ["dangerous_tool", "admin_delete"]}, - ) - - # Import your plugin class - from plugins.tool_blocker import ToolBlockerPlugin - - plugin = ToolBlockerPlugin(config) - - payload = ToolPreInvokePayload(name="dangerous_tool", args={"target": "prod"}) - context = PluginContext(global_context=GlobalContext(request_id="test-001")) - - result = await plugin.tool_pre_invoke(payload, context) - - assert result.continue_processing is False - assert result.violation is not None - assert result.violation.code == "TOOL_BLOCKED" -``` - -### Testing Allowed Requests - -```python -@pytest.mark.asyncio -async def test_tool_blocker_allows_safe_tool(): - config = PluginConfig( - name="test_blocker", - kind="plugins.tool_blocker.ToolBlockerPlugin", - version="1.0.0", - hooks=["tool_pre_invoke"], - config={"blocked_tools": ["dangerous_tool"]}, - ) - - from plugins.tool_blocker import ToolBlockerPlugin - - plugin = ToolBlockerPlugin(config) - - payload = ToolPreInvokePayload(name="web_search", args={"query": "CPEX docs"}) - context = PluginContext(global_context=GlobalContext(request_id="test-002")) - - result = await plugin.tool_pre_invoke(payload, context) - - assert result.continue_processing is True - assert result.violation is None -``` - -### Testing Payload Modification - -```python -@pytest.mark.asyncio -async def test_pii_redaction_removes_emails(): - config = PluginConfig( - name="test_redactor", - kind="plugins.pii.PIIRedactionPlugin", - version="1.0.0", - hooks=["tool_pre_invoke"], - ) - - from plugins.pii import PIIRedactionPlugin - - plugin = PIIRedactionPlugin(config) - - payload = ToolPreInvokePayload( - name="send_email", - args={"body": "Contact alice@example.com for details"}, - ) - context = PluginContext(global_context=GlobalContext(request_id="test-003")) - - result = await plugin.redact_pii(payload, context) - - assert result.continue_processing is True - assert result.modified_payload is not None - assert "alice@example.com" not in result.modified_payload.args["body"] - assert "[REDACTED]" in result.modified_payload.args["body"] -``` +## What to test ---- - -## Integration Testing - -Use `PluginManager` with a test configuration to verify the full pipeline — mode ordering, priority, chaining, and condition matching. - -```python -import tempfile -from pathlib import Path - -import pytest -import yaml - -from cpex.framework import GlobalContext, PluginManager, ToolPreInvokePayload - - -@pytest.fixture -async def manager(tmp_path): - config = { - "plugin_dirs": ["./plugins"], - "plugins": [ - { - "name": "blocker", - "kind": "plugins.tool_blocker.ToolBlockerPlugin", - "version": "1.0.0", - "hooks": ["tool_pre_invoke"], - "mode": "sequential", - "priority": 10, - "config": {"blocked_tools": ["dangerous_tool"]}, - }, - { - "name": "redactor", - "kind": "plugins.pii.PIIRedactionPlugin", - "version": "1.0.0", - "hooks": ["tool_pre_invoke"], - "mode": "transform", - "priority": 20, - }, - ], - } +For each route, cover the outcomes its policy produces: - config_path = tmp_path / "config.yaml" - config_path.write_text(yaml.dump(config)) +- **Allow**: a caller with the required attributes passes and the operation forwards. +- **Deny**: a caller missing a required attribute is rejected, with the expected reason code. +- **Redaction**: a field is present for an entitled caller and redacted for an unentitled one (the "same request, different data" outcomes). +- **Information flow**: a session that acquired a taint label is blocked on a later operation that gates on it. +- **Delegation**: a passing caller mints a token with the requested scope, and a post-check denies when the granted scope is short. - mgr = PluginManager(str(config_path)) - await mgr.initialize() - yield mgr - await mgr.shutdown() - PluginManager.reset() +## Evaluating a route in a test +Compile the config and evaluate a route against an attribute bag standing in for a caller. Assert the decision and the transformed payload. The `apl-core` and `apl-cpex` crates expose the evaluator used by the runtime; their test suites (for example `crates/apl-core/tests`) are the working reference for the exact entry points and fixtures. -@pytest.mark.asyncio -async def test_pipeline_blocks_before_transform(manager): - payload = ToolPreInvokePayload(name="dangerous_tool", args={"data": "alice@example.com"}) - context = GlobalContext(request_id="test-pipeline") +A redaction test, in shape: - result, _ = await manager.invoke_hook("tool_pre_invoke", payload, context) +- build a bag for an HR caller **with** `perm.view_ssn`, evaluate `get_employee`, assert `ssn` is present; +- build a bag for an HR caller **without** `perm.view_ssn`, evaluate the same route, assert `ssn` is redacted; +- build a bag for a non-HR caller, evaluate, assert deny at `require(role.hr)`. - # Sequential blocker runs first (priority 10) and halts the pipeline - assert result.continue_processing is False - assert result.violation.code == "TOOL_BLOCKED" - - -@pytest.mark.asyncio -async def test_pipeline_chains_transform(manager): - payload = ToolPreInvokePayload( - name="web_search", - args={"query": "contact alice@example.com"}, - ) - context = GlobalContext(request_id="test-chain") - - result, _ = await manager.invoke_hook("tool_pre_invoke", payload, context) - - # Blocker allows (not in blocked list), redactor transforms - assert result.continue_processing is True - if result.modified_payload: - assert "alice@example.com" not in result.modified_payload.args["query"] -``` - ---- - -## Important: Reset Between Tests - -`PluginManager` uses a Borg singleton pattern — all instances share state. Always call `PluginManager.reset()` in your teardown to clear shared state between tests: - -```python -@pytest.fixture(autouse=True) -def reset_manager(): - yield - PluginManager.reset() -``` - ---- - -## Testing with `invoke_hook_for_plugin` - -To test a specific plugin in isolation within the manager (bypassing priority ordering), use `invoke_hook_for_plugin`: - -```python -@pytest.mark.asyncio -async def test_specific_plugin(manager): - payload = ToolPreInvokePayload(name="calculator", args={"a": "5"}) - context = GlobalContext(request_id="test-specific") - - result = await manager.invoke_hook_for_plugin( - name="redactor", - hook_type="tool_pre_invoke", - payload=payload, - context=context, - ) - - assert result.continue_processing is True -``` - ---- +## Integration coverage -## Pytest Configuration +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. -All hook methods are async, so you need `pytest-asyncio`. Add to your `pyproject.toml`: +## Running -```toml -[tool.pytest.ini_options] -asyncio_mode = "auto" +```bash +cargo test --workspace ``` -Or mark individual tests with `@pytest.mark.asyncio`. +See [`crates/cpex-core/examples`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-core/examples) for runnable programs that load a config and invoke routes, which double as a starting point for integration tests against your own policy. diff --git a/docs/content/docs/vision.md b/docs/content/docs/vision.md index 645a7d72..a78bf50c 100644 --- a/docs/content/docs/vision.md +++ b/docs/content/docs/vision.md @@ -3,88 +3,63 @@ title: "Vision" weight: 5 --- -# Universal Extensibility for AI Security +# A Reference Monitor for Agents -AI agents execute across trust domains, calling tools, accessing data, and delegating to other agents. No single policy engine or enforcement point is sufficient. The execution path spans LLM proxies, agent frameworks, gateways, and external services. Security policies must be injected across the entire stack. +An agent backed by an LLM acts across trust domains. It calls tools, invokes other agents over A2A, runs inference, and fetches prompts and resources. The model deciding which operation to run is untrusted: it can be steered by injected content, confused by tool output, or simply wrong. Authorization, delegation, and information-flow control cannot live inside that model. -CPEX is the **composable enforcement framework** that makes this possible. +CPEX puts them at the boundary. It is a deterministic reference monitor between the untrusted LLM and the capabilities it invokes. Every operation passes through CPEX, which decides what happens using state the model cannot see or forge. ---- - -## Hooks Are the Enforcement Plane - -Hooks are standardized interception points placed at every boundary where an agent acts, before and after tool calls, LLM completions, prompt fetches, and protocol messages. Plugins attach to hooks and run automatically, keeping enforcement logic separate from business logic. - -This architecture deploys identically across the stack, inside LLM proxies, agent frameworks, and gateways. Each layer runs its own plugins. Prompt injection detection at the proxy. Tool authorization at the gateway. Data loss prevention at the agent. +## The state the model cannot forge -![CPEX hooks deployed across the agent stack](/cpex/images/distributed_hooks_control_plane.png) +A policy decision is only as trustworthy as the state it reads. CPEX evaluates each operation against state it owns, not state the LLM supplies: ---- - -## Hooks Need Policy. Policy Needs Context. +- **Identity**: who the caller is, resolved from verified tokens (subject, roles, permissions, claims, workload identity). +- **Delegation chains**: which credentials were minted on whose behalf, and with what scope. +- **Taint labels**: what sensitive data this session has already touched. +- **Audit log**: an append-only record of every decision. -Enforcement is a three-layer problem. +The LLM never sees these and cannot rewrite them. That is what makes CPEX a reference monitor rather than a suggestion. -| Layer | Role | -|-------|------| -| **Hooks** | Where enforcement happens. Interception, decision, transformation. | -| **CMF** (Common Message Format) | What you evaluate. A protocol-agnostic context envelope carrying identity, security labels, delegation chains, and content. | -| **APL** (Attribute Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | +## APL is how you express policy -![Hooks, CMF, and APL form a unified enforcement stack](/cpex/images/overview_vision.png) +You do not write enforcement logic in application code. You write **APL** (Authorization Policy Language): declarative, attribute-based rules with explicit effects, attached to the operations they govern. -Hooks make enforcement **possible**. Policy makes it **usable**. Context makes it **correct**. - ---- - -## The Policy Spectrum - -Different policy types require different enforcement points. CPEX provides hooks at every layer, from soft stylistic policies enforced at the prompt level to hard compliance requirements enforced at infrastructure boundaries. - -![Policy spectrum: each policy type maps to a different enforcement point](/cpex/images/policy_spectrum.png) - ---- +```yaml +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" + result: + ssn: "str | redact(!perm.view_ssn)" +``` -## How It Works +This route requires the caller to be in the HR role, mints a scoped downstream token for the backend, marks the session as having touched secret data, and redacts the SSN on the wire unless the caller holds the `view_ssn` permission. The decision is the same for every caller; the outcome differs by identity. -An application or framework invokes a hook at a critical operation boundary. The plugin manager dispatches registered plugins (sequentially, concurrently, or fire-and-forget) and returns a result. Plugins can **allow** execution to continue, **block** it with a violation, or **modify** the payload using copy-on-write isolation. +## Three layers -![Plugin execution model: agent → middleware → hook → manager → plugins](/cpex/images/integration_execution_model.png) +Enforcement is three concerns, separated cleanly: -The plugin manager handles registration, ordering, timeouts, error isolation, and payload chaining. You get a deterministic enforcement pipeline with no surprises. - ---- - -## Where We're Going - -CPEX is under active development. The current Python framework is production-ready. The roadmap extends the core in several directions. - -- **Rust core.** A shared plugin execution engine with type-safe CMF invariant enforcement, replacing convention-based rules with compile-time guarantees. Python (PyO3) and Go (cgo) bindings enable a single runtime across language consumers. - -- **WASM sandboxing.** Portable, capability-based isolation for third-party plugins. Zero-trust by default: no filesystem, network, or host memory unless explicitly granted. - -- **APL integration.** Declarative policy pipelines that compose built-in attribute checks with external policy engines (OPA, Cedar, AuthZEN, NeMo Guardrails) in a single evaluation. - -- **Plugin catalog.** Discovery, versioning, and installation of plugins from registries. Multiple instances from a single manifest, managed through the CLI. - -See the [GitHub milestones](https://github.com/contextforge-org/cpex/milestones) and [open issues](https://github.com/contextforge-org/cpex/issues) for details. - ---- +| Layer | Role | +|-------|------| +| **APL** (Authorization Policy Language) | How you define policy. Declarative, attribute-based rules with explicit effects. | +| **CMF** (Common Message Format) | What you evaluate. A protocol-agnostic envelope carrying identity, labels, delegation, and content. | +| **Pipeline** (hooks, plugins, execution) | How effects run. The mechanism that executes a policy's effects at the boundary. | -## Projects Using CPEX +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. -| Project | Description | -|---------|-------------| -| [ContextForge](https://github.com/IBM/mcp-context-forge) | MCP gateway with CPEX enforcement built in | -| [Mellea](https://github.com/generative-computing/mellea) | Agentic framework with CPEX plugin integration | +## 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. -## Get Involved +```mermaid +flowchart LR + soft["soft
prompt-level
style · tone · refusals"] --> mid["enforcement
tool / A2A authorization
redaction · delegation"] --> hard["hard
infra boundary
identity · info-flow · audit"] +``` -CPEX is part of the [ContextForge](https://github.com/contextforge-org) ecosystem. +A style guardrail at the prompt level and a hard information-flow control at an infrastructure boundary are the same kind of object: an APL policy evaluated by a CPEX reference monitor. Only the placement changes. -- [CPEX Plugin Framework](https://github.com/contextforge-org/cpex) (this project) -- [Contributing Guide](https://github.com/contextforge-org/cpex/blob/main/CONTRIBUTING.md) +## Where CPEX runs -Contributions, feedback, and plugin ideas are welcome. Open an issue or submit a pull request. +CPEX is direction-agnostic. It enforces the same policy whether it sits in front of a tool server as a gateway, beside an agent as an egress sidecar, or inside an agent framework. See [Deployment]({{< relref "/docs/deployment" >}}) for the placements and [Overview]({{< relref "/docs/overview" >}}) for the model in motion. diff --git a/docs/static/images/cpex_overview.png b/docs/static/images/cpex_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..38f31086a74de426018669727d11688bd81a1b92 GIT binary patch literal 359890 zcmeFac|6r?_di@h#)u4Mt_&ekna5Jetig~mTgn&`k*QtAWQa%z6%FQ{A!Le;C}ZX^ zWS$Az{H#y+eLD8}()ag!J%2pUALn(y&arPkd+%#l*IMuOUhj1sKMgenO0olFJ9g}# zR6Kv?;*K4A*mvw8t|lRd-wYF5J%JxP?Jp{v-jQ0vJOcm6(^Nts zj$Ozj@V^~959}cN^?Jt+rJYQ_UtiqG^WSF(cI@ym-$D4_XLR8w@=^);*YBUZ;&%T1 ziE#vfJ-vrLZr5M0iK~(KEg0MygCC@}=dalB*m1lV`DZ83HQkOKJLGmKo;h{dY3Fzs zX|$$8m)QCgh2;JFp1!#@X3xB(ZwBRl56RI+UB4!f^G3bBpZ?(yvWJmjdhg=Q=goYt zUmplmetpC*Jm=+p8ZOCQp6nDGOWhv02f0IEzdp~sGuVA%YVK~e<)C}v(8q#W+uEkw z*cH-Gr|#|8NkB+K%`W%eegbujDbdt$A&2Li6GM|0` zZ?AbUa;mDTv3atNF`A1GN?qDLH0n2;@E*b2nw;|wB_xQ#V56ZO1aEw3S zyp&^m_R)1n>l_Ehc^f&Ea8!5jD&LlY-Uj8=q&;gW29?>rBFLH2;_Y-q&bwYdrN7 zbFU2HysMB~z>Z*TJQmIL#!r4ORSJ#XTD=%&s7 z^}cxeS1&G@hSE`-g%EpARuQD zV7|Dx`-@RyKz~r!(Xz%^yeZ>7eou~+Xq1;jO+WAU$kUadBbP3fm{X|Yji`EG@V!Cl=qN zC*UAuuL-9U;BQ(8Vgi9liHUgy=S%l{|6v|G&{{NamE~L$Qfc~Pcsa6Za?CdmxbDo( zCiX~@fs^>3Z-XxLXJEVd)YZw-Ijv!I^E>{>Y|hiXUD4~=V~}^-`TN9&p2?6#fpd5Z zWql})0M(Hb6^1v=hdf|m8LB>eSh$)fbG7#R2=Dvn;^2Ks3crRnshxfhtF)TcfVrvy z-XD(;@Up)yCc!_CrX=|r>nmM}Nl6tgDO$6}ruxT|4*!qg|80@MyvR4SB8TgBOdJ|- zw7QydL|Cl>k^OjM+^2VSrmw7}CBXR{ra~Cs+2I)_e3ms=x>?(M)7Y`DkBctW*#xxs zgH@9wI#Wb~cg8d@1am{R8)_nNbMg-P@C$OBITgD7+7;%Y$H` z*V18y_gjs_Xk~%i_`FMR)I^&ohl;HIuURnu>GMqw+v=#QR=+UKWL4(aw1#pY_r0}v z`Z3!a@6B(W&|c&WtXB*Sqzn5*QM>TId(*prE%9NpWc}RPCRXXYRz@v3$7cvn;e8fQ z?mgbUoZKQi@mBuH>A1v13#TLH!d!U2|L3X?aA+)wewr9fWM?&GO~R0E{^(3~aD5YV zc^$%=KY~{m+0)(4YnYiix&l&v?thMV^H;=OK!fvgO{))$#(#{HHu#>-aot_`n@mdN z9cF7KkM~PFGlPSsAwwW3sGno|arR1J7~Xfek)D@>CF;1j*+po~AVxA}@WD@>*Zq(P z{wV&j?sB1o5bXoa8~oJpoD%va`FS~hiFZ3Uf3RHK6N2Xw4$DeiI0Ged9}M2#@D3k* z3lAGQj}ZIn%MD?z)0_T=Uf_gC>1JT_5f2@dk;9JPDtg%CNx6A~o8I`J4bku|J|`?J ztjEysDHm@l8qBbBWp*dYHg6nBW=wHhb5xZI-lP9qkS7o14Ct_KRmLpMoBxDf;DV7B z7}%}gjg*L-Unl+QmpobiRHILN>9;qV&;J;DB-q-W5s+Tun)TB;cK}=?m-J0qSJOT3Wx^{7rD|Mn^|?(iM?1 z{nI_*dGNTa=sYUEuDtJR5}Q6#?w*`K5^W;tc5MDo_Bvex2an60`B{U^=xzO{QbK8uw!MO#CTIhH`BaLy4RZVzYcbQvkqgT;33GLri>EaG~di!h*S7}u;bw7 z?|OewLwhGiL2CiUw&_{C#rt)R_i@2<_5Y`1)!H}RgGuqV=KYgdnR0!f@-lTi#lH#7 zqsPf{RG4ui6Ujftv(5Isc`|>kHW6~_BUE<(>D&J}TWa=TH(0HZ5io&|V*i(S{ln7sAqI8zILrUtyYP%aZUISrHV$-f z{NFCZrZ45uf#dS&8vfb*XPEf&=7NEom;1UdoGt(VVskapA^o^tc^mJ5{a?~kUP?Y|D>xB30+K=6;= zzYgRdXYgMKg70nqbs+c=?OzA-uLJob*810h{Odse5mWFG+P@CuUkCE91NjZe|MwUF zB9MO($X^t}R@U-w3iFR}^Zy?N5@)jT!R(Ke5bve$FML?Z<_4?3=&fFlvZiPwyEXbOx#N%_vr_0o{^D}wu@bN(l9}|6%XuQ1y531F3(dBh*RnZ@vCINC!J}7 zPFyH(`-oS}LMzeeWSZ57YHDh_Xf{Nq;MHfn$R05Y9n^Q3$nxt-Em~i;Eg|JToJ~r@ zUgwIg7U|&s8ZCap<{sPnv`_DkQ1u#ra_YC&&>&>-q*a#^dc-CxW5a}@d3hKsMgQM* zfI0?9Mt0ASx6M@^*A3ZR&5&cCBWQZo^MdkEq}W?(bd*h@xbV_+-dJj=s}4s?{qc-L z>DMYl#;wZLNLCftwHBEr9jzFTUppV8nC!{e@3Hj7Xto{SN&a>eac*Q#O^a>mO8o;P zSf%p4k~@}SD8}VxU!p)EuJ4#tYx=K!0_djkR!-$~{zP_1N!+Oivw7u*v1uM_W5&(N zSDdZ43FLL_gc8|BM>~YJw~Za>SsJII=XXj(iQDqPFy;$xJzv#`MtNG$DO0Sc5 z+dM+@&domag1hr%U3m`5R(v0>(l6B4v1&Qrnd+8K&Zv<{VhvwgY;SL$?)71NotlxC zC-HNvMKH?%2t#W@>QL&boc_S{mC27ji#;A50YudiLWh{`zY6t!+RaA6B6-)c@_-oY zZ`0ZQBJMdUt_V9Ze!oLt`x(eNlk#7Af7o@x(un-m`E3Qk56jJh`DP7G&9Ihco$oUh zY*Y%Y&e!_P`^y8qXns(#;?MZ+9PDgzdcAuGo*Jb_VC!&_x0|orNYQogTZ~=(!QHFZ zv#f=RV&6$Hz)|-`J=V6x;EvbYLWdx>$|s4fR%%WqXS#&EG(yNki)v*X*-iU1IS0v( zw241i8V?`ZU$9Utf2N$t*>bck;|8uN&7H3Aj=}=bYxP*x)Q^*S4_k-t6Oos|IJ<{+ z->6144~wfoscwRZcn@>L@~V9)rKl$?aM}ceInmYzSa%)B$(*scHk%XLo}uxmZd z8+jc?v;Su9KsYrYyF`0XF}OY^nbqmi_dU~1+D0tbYhw%+#xmu=cL*H2JzQtopvcYX zD7G@5QQDbnPuoGbwSE7eq#JP*MD#~>_o;~wJi&&sIRALun_jv{*^2gSs@&>xM)6zM zuGmZ$EOwKNb~~9|NvrQ8p*D*tetY3nNjZ~Ebc;9_GZmYRl%T*r!q8t?&xAZs;VU^c zj*7nzWogd)s?o?+7cL3VX>}d3LWCaushw?=VE={#JWphRJSP=y*~<- zf{vD!$-l<=KR3vfZ@SN4(ECQLn`1y4IHg>x7QL@ts?UydK4KCTu=~uHmVf-`M29Fw z_1FqQR#sN8OV5hom7718hGMC}ZyQ}nt$qnL^T1;Fl?jGGndM>Zry8lbuj#i(8vb%F z|6|QQry_*Cp`Ik3t_uGY@?1jetR3TuY?57QyAg{R!?JNu$FpK^$$MGBiM(&0uL|Rn zcS+RH)xy!s$AuA0=FJO6V*e*ealYZ{_~ zHyrN6Hk!{R@9b$Y@^CZM2WLwGroeAhm~SQGQ7gUZsTj%?R(+S>a|gPC^ykPo?nkWB z5_b2;Qa=iOjg^(%eshk%=0vqLrEb4DgjE{sT8}@1C!p090e77a=Z7ns^tv_(MtJS) zZ3>sJ=YZ3u>ClzL)hn=hAxGV=UG2F9XE_lm^;;|Ss~{N(*TEC9l^xg;FY;0d&k@@} z3-mYyk*||kStqt}_^^%WG!GU-_oab+O^1ng;b{9Bac4V#r>;{jJ<44_z>Bcb z^Xmk+ck5D$cG}82ZLBTQzLA{B{C0YAvg_9geyfjE_8UEyBJSzzsZ|Y2vK;um=9^!< z(WO+fQdUK43(ib_l|G_fw=Ws$|MU3%7xTRT zKiM>u?_~@|6oUEFr@IqT^Ep%QiyvW)7C0bg(1DeP=Cwuqm;>~cko`2T_x%?Ea)0`x zO3{NQK#NXJ(q2oQogG^HCbV7gV?`*TlE+Vx@2)!GrDl^D(^xjVsfc@?^?Xe}=!7ZDMB z9DlEjppS@S#;3b^X2gHK^cNs2!j%cmwkxqj95+?Mwbie0oD9l165Uew@+xhc36M#(qYM57jl~ZURUI?fq1bO#avNV zG>DsQd~Rg>iRgv!Mzz%J=R>`i3RY$P+QOAdrae;A#lQULd+zB0K@ahtlU;KK+#x)VH!#RS$<^oq_&T?fax6hp( zH_@qaN|9D}B!*V`lV=*CL7?ls_t3d`0)5w+vR?3ZO4fo}rJ&qoHS}1U$MyzFPUfU{ zId*U&Xg}Bho$0ysajH-foVV0RrVrbAIue%sn}H(Uxl(1&$-QOz3)F(#oqn+9Ig^Ej z#VsSqJ6?EftoMG4UH{2#RN%6BbGg{`)?h<|a_N&Jmr8ut+!+VIKBEPI!vgM+x^KPN z$YU+Nj9%|Yp-8*gtGXBRKEhhkZp%M~SJ6X4t1xluyImVNg-&92@t=CqZ z;iu+t`%{)h@EC2a>_V%_+&T+q3{Pt+Vc%4ly2tXjPJ6qxfQq3uBxFV3>2{uvdbl9g z-68-^06TL)Iap=HWsM4(zEOJ~;%ye%dP8RJSvsH`9|l7w zu5P+bbTnIf}BRNGOSn%^= zXi|0=;XR0YVy=)s?ZH^6sI2o=9O7bL@1^n}QI}b|<}{f*6nWWzuaLt%qgsR}uUhtJT-N zNBU=-ZL6`5SG8zQK1P5jtK#sxr$+ALY3j+r`&t;%6!HZWD=OGLqCehLv{uAK9LxKr zCdnxrrpkYG=S+l2;E^a{-SbN%*dLR8sW}H1ZBCeL%HlpzomRQXulfEb0P5$;R?3kp z5Th$Ot+})O=#1F8-mR@K7S&1{sYQzkVTUSSGYeI^R^SCN^shnqC zJS^|SVD}pa0e!|_FcZ$vq5Sz~J?F*c!&4E{F5l6+2MraMT zKf&b3DH&4Vl)IcN_{!=3ZX>#x4y5LJ-PgCqerPfpUz)_Rz(`n8 zrqQ>=P{&PAB5o`roR;=@zqZP(Ul^+o6&tJf9+vj21XnY-l!k9t9eHzjJ+MD(Sa7@$`0KwT+TUOz&XuTry^C_d(!})d!?yX+IyIs`rb0L`KaO23zNb$hXCFc} z_N}S8c`%XDZGg{+6r!AgQ=cFAXl#Q;kO@(q&XNGvX%X(FW9BbvWmc?mz;Z$(b&oDA zblCU|OJ4gNe4U%E*=T%as)$Vuz~Qvf#=KE4umq}WgIX^6PBVSNgNg6nRcfAn*hKC> z^LhgUIrlguY`NI#i0EOSm|~M4>2NWfj; zBQJ>r8wl*%WT%H9J3euX!+IMK^zhIz@cYz!>T9!4izvQ46%?$An?L7A_quSoDa{)Q zx(|lRBW&f@vLKG7sM50g7kt5StwL~E$vylDvXl7M(xGzyFxH}(02|K}wNfXrqe0fr zUn7P2p1R_Gep6>~*w5zn^FnH(ZgRtw#GbX60y(FN{VQ`7eF(9K=`c-1*=^UOuvKqn zqH*~QNg4EWxVhMk=(|SkrcVuSRd$s{*XnGjvvavuJoEuCVB$3WY4?Va{9X@Tpl^KX zZ2B#uMSEN`d=H6AT@GIi&z^Ill3x4yt@QKbL*LZQ9i`lNe7aM#(v=RxlQ)F$4{iOK z@h0tSNsMg1bs-KT%3J&T1VnQ#p?7F&dAXbysY;BL_&Cr9##K*_a2h$Qpd=x%l^*@j zd2u#ZOZAxJc&q+EX+&{z<11+DdZGMO;Dt?3;R}$Azw5d+Bt7@_1gCnlzs)Stf>tWr zqJPP@LI1^yUsN8XQ@1KT%HBS_LOtlA7D)OYY}CUEj(pU&0!6n;$kA@;slUAFewG!Zk_ajiGvt@ZKjSb~m|MINyWM^*fk95TOL9^QtY~n3*1r;1Z$H=RY zJks=2a+dm8C!d?!7Y)Dv?C*EOWgMe$(?ib+b9T5Tt%v(f#i$~fX-{e6yG!;>q2&Qg z3@G7>%tKu4Ge_=^0#%W=!ynRMHAJ6| z&j9u3z5m2@&z$bvtWNCtKO2-O_bhhW*ACEVB6**%>w)php5+mlcOeG&eCTnPw9CY~ z9L^RM z7BepKq^A8A5_l*`>i9U2oQkTbqx*bo$AtXWTZ+~lLeI6|{5Wyib=`L{6ZdJyj12vh z*Nnp%l}ABrano2@^&c^dOb3o}NtCY)A!vMM&3w3XFx%AGhVof7P}Y=M&jQofObCYe zNRCr4{~V#cJ3kt>lI{&aOWQSIe)v_8H}HF+Csk9AcGvEvVvSZl6S%nEEYUZ2O65^t znxK|!-`LxT+wBcQubL`;p7?DOcrTv#05vn4vqx(i9B<77$Dhp;=RM;cLS=GGTZ-F# zeyXSFQ&V}B=uCvHhkGijTity&RR6x7?i(mV_yCn3mVB@s$;|Y#%7Dnt9|2 zOT=|P$%s;jH2uXPt=)OUFa1jZ;zu~?c78*6x|N5dI}w5#VI4~8wDtE+!Y%}dr;A&+ zXWBMiQ1FUqZPO*SMKXK<6r!^Wduh+riVsC|aX-fP_z0PlpNyQIh4XQY*>(C;?is&) zMJ7_L@Yy2oK!AF@*u%}s3M%?JK+}%-Tw<%pt(wR6vu#A=Wa6f}PQ|?=fQKvu98T#0iKRuEHixQ797#)ghmPR!m?>l6j$w^G#K*B$+-}B{^^u>h(Z+I%{o-Tq@B^|G z-+}C|((JJwpI2bMp*hfmq$%27f;FMHv4^ovY#*Wf%brat__mt8$(3$Bd9E3GiV&H7 z9Pa1OCf{Y5YMK#NFi~(VpqIrS+&}mz;no9pf$$KroCX<5;)wGBPS}Hdp?C!aj+L8 z?o2YL<_<=7GF9|5rIL%IG@}seqxZ=rHB!4kV4C@|TvXOwz<@4wM0HTp4kMvo-=RES z2$hfz*_6oQ;C%doH%dr|lj|(D>aox%&XfjY6%8qf{3XTcT}%g3CRom zKSIVAdOSAnmj`fHRD}z*O~DcRYVaZRp)W>PC-dmxE2-bhR^P}cH!y}i(vn3IYWYegF?MV2QkEcY56dJc)2s*;p`lX&-Tldw-X zhrwqzynzH6oa``XdkB@0{xQr9J^_#m1}J9J23x(gqeAA z4oS6Ur?(6&P$N1=0M5{+kLhhcjV$o2UdX95ZcR5PDp)F7UuXtm8VS3^N#|7|w^&`O z^+@f>Dho21kB01>JCHU>qFhNDqjXg{P{wW9lWlESA@^ENrXAu=Ghg5Dx040*=3r6e zw%8>q1pwlOc%@Gg)qx}~;FMqGCSFRa(B2R)Y%6j{eIK1DTV4Xr+|D=5tFJyq@53?h zyq(%F;0}5hQeSMRchK^^0ny) zO%?XV)>cJJsA6F{|Om!POUtM zIvWRW0_?vZLM6NZyR0lW(R*gOjOyJmjA3n1owedxadW=Wo3083WU4p+*ruE1b09*W z>nlrcZeIMGU_%oS4OT}zjnv0L^z%d<7g7f%3XF8Wo9rzHM0z7_YEIw7#_Yk92oHi} z3VsIAetRqImI&lo*o8sDe*N7nw>3~LvgT zNT)aNhV|V`$t9H{QM*_|*k>=FEWlL6%`&qjt#xj^q173GzHViP*Wm7;q|h?mIFzFL?gC9xBT%V$aFTHjrI z^YSL&BsW*@1mr$`{WXB;!I-!(DXpKxYJud?Z!|U``T~Y6VfBvN%ESk5$ED688HPaVg;uP{r*Kki z%T9!+=wqKLWDf2AcxNi6wL9c$GTAER15ysYI`5nS55AjFHUB)@9r=F4sRo=K-6Ygs z>vHXfUOSr4I?p(qS5Z?{KD-9$@M(F!D%F^`q})ik&(G!ZP7JA&+Ljh8M+B9J&u?daa}28hW?g zuG@JiK{$(t^{5ZU{t&TqnKuj|ZGMF|^7l>NgdU2uqG8<|F(~XzZy`cUn z`C;V29tj?#gw9FCC}O4KYtyMF!KE7cWZz9K{-e)?xJb+0o{sufT7jQabNcaEAtW+# zpMRw=;A%xFQ7wbzpKoNJw`wJS2cHwA+^Tp$aB|{??8=pkm+DbatGruZu5@4+BDD(H z5DKY~SVVbWLnD|S!=V*$i`p!;n3&0mx<+Cwxp5K_4|jxawOC2?#_2OBO>(h70&ukz zij^UeSY4j!sGe0WR{;Hfudrkv=4GilX0Y_c3IPY`h$$F@#gvkRb6l5ZvDxMKA%2>; z7&W;=E%E7U*7@i$uv&5-7RS~^^A?7@}nlGDPT_-0{xB6xtdojIh7yU%E)(3oFwxMI~V)wHN61Eamba_ zAC#u)U=9jDli;eT81)HkAc^m)5b3lnoldcFOF9|a%#?*lf`BS~$FR1+whp0+%$&_I zxYao8YWQMyyT~PvM;nexYdh(a)Mak{_}s*nI%A)Fzx9AZ`x<`=fvsh7CG*w>rf~oq zXM&wDFJ7MU8%|!1kgEt}DFyaxFY2YXB{Se&m%fuHZq%qeVmRcs@_7aTOsbY#FUJa;-%V z6bO^XO~&K$QnL)@%Z07s@ET8l|K#^2O3V(G)S2;Z@&;V0qN@LteVg?_YU5P}j#_GX zIQ;k8((zOGh{fD)Y;N|3YYDi7>Gt{4QGW{rf=w{xo!LyL_yMIyR%WxHqB<;6A-ci_ zr87V41hVook2Cv$5LL6ZxRBqd_$rD110c}UWuYm!5ain&ieQAmd?WB1jYxw+!)bP- z?@T?oa~7Ok5fpAfa}C`|^lxlz^hEeH%u%i&L<)gXO&=gI4?FVFVT?WySd?&ejbaj{ zN?z(ssXV@yk6q;FfrIZZ0V9;z6W-XPdoKj4nouMaQ`E=s^Hu4 zT^k`ablWh(Rn$NQ?Qf~%-G*K>A%NUot*ahHEEhW>+v_wq%Q8njRarhB8A_6=<0*NXeU(InqODgS5Y*gUe@Ds5UK1-@w6yC( zX^c53BZyjh1?5Sz^`UP23o<=UgY+i&Hk z19w&HW}k&Bh(XRm!_>E%BR)kKBX4kQVwHdVsCDgpzj+$@Xsq_v3b^fvOvp}b1oy>g zJ!GGN1^AaVSi39QEU0UvEmgErul1kd-HkfA(N4^H@Q$UR2c8FGPS2 z_cqX!AVrXH^}0uUf7L~xLdUfb4dgpY>PWXCI=)#-ZMSOMw~+BZ7z9kl(F zL>mhqioBqJbvWkYO-O_uBZbsHT|SOhja&LhX!!U(#phb8B;Ct_Bu4no`8ARsr1~In zcmY?6IL|O5rEdDyD$iyWJD?z60$^CyIe(c7Qa>e@8}oCkol&a~@0iaJt>?tq!^J$JhkU?+liP{lC2+-V3=g2ryP| z_9wOxcA-Patw7NA&K{AMWTog(nm5gYEUn=F8>e0`2AUN_u4DDSk_*oL9(NFbC_aBF zoVX`Jt-HiKkVVpavg;#v;CtZMAz-|h(~PpXvdG%(X5)*3@^zx8g&*}?4<^cD3yzdbbmi@0P$;OXc<`}Z6xTmThMzZnz} z5HlX$3Cd5o_OxiAuAzMLuIe5)(JfJJ7b|-t`s7}O)*2>a?&+;$sHfN8LU$Lqd~l^c zL~RD8p~4~%16`_>f~wPL-LO0;8+)U7?S8$`1RyzcMMqCo{p86F$Ppqlm-wer6n9v= zUX)K$fYS3ft|VobwKgt)p(9%do_%NK2v8RoDL3DFviZp>^{W73RrMPI7_)-@L#d6>dLd{ z+k3x0I}uuPHh=}l=iV@F!%8N51d~H;{1UE@p(vt7>k;5XsbDdcmQ;gMQ0_c$ZPqw; zl(g^JjTlmd^p-d1h*u>*MWsLOibt-&3-*LtyErq*ABHJI`GF-+tk0KgYGyp6cF)?% zA`}VLtO|ck_y=aNw)tla=I-_O`bL*45G}7?Qk%iqc%)=Clle0U*63+nN;5XBpeMd8 zXT=dWiEmr~^?y9_5E)FJF7W&P4N~!TPX21yLptL=tY0rwVhnH)gy|u5%c|3|8N3;I zKG_%aQQBVdSg6=2Aab{O@lui2Aw=MZNVogMg_hFwKHT)PrX>0n!h~u{$gEPP4A36U z28Bl0U9x`ddvXXBpq-0g?HqQv29APNN(|;I(+fla>S*=r&OXR$&g)-%XO!`}tM=}~__G|=Q`_t%NXV)T^1`;x zc|eCpf=xtYjyKZlS?%2;yE<+A<>&`bAcj+4hETmyDRN&c3FeTec^m^o^mLNZ8ZRa# z_Y)jPZ~Izl(X5p_KxsB+dmo65B&@GvOlhNMVJ2*#IR-k9@6aFXA8RcB}GHf(wujoE6NdRu#1 z7hVAPdmGW0(c<=lzp8rUAPdTkF9qO{5Tkd5^38Kc-O-CCdMao5%P;8ZF{y!u2~k~f z?)d;zu@4jwPGC}Ad=KGNKCSEZ+g=gSFnpjvdwvh(3l`^n~sA zVx+1i>J(VsM~Yq1xyaeqSFI8GxFO_=RxC0fx7e`dIZzNP=l#s=*-PrnX(}qV*~ET3 z@%A&|MWWK9p8P$B&CgY-f^dM9L~=Y`9uY2YPFQ3*_;{udSDv_u0F<8aieM$|ZDHNB}d*qY=K?JUqzv-SeUm8<08E%2)liK2RZY z{T{+R4QIT!Y52$(Utq77y`JkD-_G9KC z+#ex`G+fx}u8LzDNNky;xKl8YK!~@*6IKekjVSj*-9Txq{rZf6L0)ECCzk*lI+IMp zoRn!G;}+k$+i3T-WiQ067o!b51VM)6lv-u8mQ#^HYc?#Sm3Ku?=9a75x7Npywq!3V znHfQls5?hN{fTWIlFdbx2ED?os7qp~_pZkQ67be?8whw42S9T1|Ss(H4NA7qC)?p6cg< zM9LGFRmm0Q31Tp>h?Dz^UaLJn&<#sLopi38_aP+ts%Di*_|RN+c(^vWtlVn{!0mf^_9wWgluWG%BYrc z9`|XcJrB<5$9lFDitb0|t0Q#OCO!Z4x#np;O3&{_ILaym=Mfb(zqKQh`$m))<(_zA z>l6&ppb8ZHY&Zt@K4MkN649W9snsM*{`sR+lc^K7suYCDj7SAW^F@g36iE4NtjAk1 z0ABVSjX5+y`BMnpcq&f+KSG2AI=)Y$cDAPol1M4`A3u!LQFG5jl2rl$sYo}8P45zb z(B@{k3qv)}qpIo}39&E}x;KvwpuC2&mE@2dJ?4z-T7IuL(;yUWx13Mhd$@H*YhgZP z8?0dVx;XhtMhS3(G%giYKD1maB)G|NLeu*STi~|J}7HhMnGzhaAo!7 zYRdN`PTWwdA3R2lMv)`Tou53gl}X4BB~UY-xUO=u^_PF*d>Do@G)X=3%izu|d(*JH z%#UUba$RHvn>(T>kYp=gB%iP<$`-0E3!*bAe-OhL20Csz8)W(6rlzsG?Ub8+?cY;B zdEq;VndIN5q^L@*fFgtng1SniyEu@g zt!|i1sTC*lkFBG7N_neZ^B?3Q6Nuo5JUy%z95-;Ej7z{=*CN+6awD@L|EJBf*IE|F zrb=e>%Zc%YX$AK#^1ego((B|1o)QpyYxTb7>qh6$MxVcZjJ;S!wD^shIpNUBMOEE@$PF}pCoHB8b<(SBBLl4klO?8OUH{=Zr446`}m(A&Y>8Nd>|HgK< zlKaiG7vnBSHbT>)F&S&(RbmNQwUWMlf4N1sUVnjBUyE0vPl!8lRx@izB3n5I?&oaR zy~Mcb^0Ce9#d|*P5>ZOo#lP1LZ?)y3ND(pSls?ib(OtOEi2>JY3y|0gG7)cA&DQK5 z_Z6h=LE|m&#gnZdv(DhCW^6>-xO{8FMh}1_sjm*MZKVLuvM0(q`wFV)9KirRqWCBU z27mp9DVQ8rKEQj9g>LCb_)AHSpPJCXJxVQt8@ID5wNA9o5%9)apuxkzG>d^i58~kAX z8b70a|jYWdT`FU26x#xZm0V(kFWC#6ql2-_g;g*s2Qz zzT*4`NG_+y6$Tr2PJ@#70Fcg?!wYJkON!ccLrWuPHn*_WTzANVp1Ji>&9Or9QU%%N z`jDfdMnu>kw{a8BsBgtvdtVJ1^qzsJ_ODz2RW447fRd6D2~~Ep&mG&3U<#x~5us-< zQYuu#9rGqZ)FIPwZhXLJe}6D-+-XU{R9+B z43~ii9Hh|5ti5kZ(5@UYj3kbG*@IcpxQr9ti=nBmJKx8<=w(f192VU3qq5>Nx|rzqhibDRBahn^n`yET!W}!c%XZ0keKM zdxpI*`#sPVFi~Djx3;u%H*;`KA#|qlNC8dm=az7DwY{Z~zD8zcJRK66i|VGUj=a=- zev<&d#gmNEubwhX+93X+l8WAeeow*-=Ib|C&g-dYoQjRo!^{8=qsnaUe>9r}mh1+~ zs1R{Qh0>xo1uHzh*??9$9nL_xa;1A$Bei)SyYm51K8LlQ044(K7_ha0)~yo}Qg>|Z zZ{MaL_T+^)XqI%2{j5#NGqtOF+-V}KC35N7Oc0`SLz;Z6oxelPkphWSYA&3j%#g5h z7>V7U{ZVOM_!A6RY;{z-i0sm971BcrffQ6y({fR1pFAhz*^TJ1NaLTIRWWd{W^}*$ z*Q`J-=x4GvenVE-N>I}Ts*H#phSn!Y*5;$*L}csJG)&}TxJ_+9he>ee(KK&wZ(CrH zxz*YL3LkS{#bFDZ&Au+qS%NQ35y}_Z+S$etz?Au>HMYxWmH3dJ$zQNv5ou^aOc?3z z0TVvaeMwz9>>E83(hA0zt$oWxDgznz;`9k?DfQ4enN8wmZ`&gJPV9#sal*zOyD2wQ1n1 z?S|p2gfRsgaAH&H3PmSfK%ra$=tlG=@5m6ShuLPe?!^H0=^MtcY*+Tsf2sTS3+ci_ zey_(uTLLMK0I=>Rr^JObznM+E7g75drJQX0q^&idO+#%+CAvtM=@)(48>!pIV;6k< zWDLmR{4!Dv3nY+)8?4zZTEf8s(Ph=tVGi~oIn=jQjxGYB;ChH!%$|t&fpW({XEIoG zA~mkzY2lNcAVy*&3em3Uezb#-^Q$t+ywI~g(eta*XGNq*r`n~Xv0LV`U6wd*h^(Y>ScUtz*#+G#MSWTyot54FA?Ao+qUk z^lrgopJ0DWpg0uO+M*l4VB~6NNvUZY4SF){I&ye11r1CH(}IxCSW~rF8R^V2w)G-t zd+q($vv#xZ0H{NQtT?e)wSaeYzM5}kKp;Li4*5tARmSz|D)Sa-q?#wg%FTjb2+#SX zQ@w~#B`70H;pUbWpG#`uocsoPC+$8DUY6}VrXf6KUCJstKV|))iymcKa?z+~bx>5k zQ1lt2#BDQ1VT;R^;kK|IT!C&NI^)c?JiMj43Oz~V`tQY>S( zE#8S#iTy4iJ@79~Dgyo74tzUm3yoTw1lYu90tAE&oYNp>6Pxk|x%N2H5jr?wV_tz! z9Y|XS))+NV>PO4XwLsSao6pz$9g*tIRFf$=tU=~Q2?AjI?p{dbI+4Bi{j>ydQu;;& zZh8Fkp`fqG;%*s=nhvqw(~-Vm&b^J}BSb^1h1gWrQ^ULFioC7Z(Q`tFTeMoBZc6h; z7a|sNRRSAQDn=3yYQBS6Px**<^tXqmHYI8MfcD7;M5BitKD?a(ny%8D%E)cL32Ub& zF%m!Rr|xH)nPB3$4#E$Xav2!sH?Me9wp!Co^a^lQQ?y}Xx<`p`x`mOx&*6cNVfit-=@W zJopTes#Iwe$$&5@lKIS|9;TV0bFN1Z@bS^-^^L{gB9kt+AbULxlN_L6dk{p+t@Z~G z!3iG1V8~n)fBpY*Cv}AATK9zvV}wFNMBTKpx|wDdM8p_2rdL28PRSi@d-?+18ohvi z1SM-XL@N$0RtwkXw;L2W)<1b9Q|CYxth}p@T`rj?;ZuZ)2xRvA^Y^6$+-aBn`Y z&);t?!RnDFeJo_5;|iI+J0Bv!0IMX9IjFCkszb-CwY&s?5ey7hF^dod2#Oa`x%-*J z$7(icPCHe9|2p*dUw`1E=wVhgb4^H&q4tmw^{svvw-~--K)iOAt9*fCQ|Z&fp9$E) z@&KV>nk4^hA3kC`6CNB8K`V8)Oy2&0b3_TO%7%u^yCPt(PLx19tih-`O+^uCpmnV$ zE!q?gJ}DQWV?1_M&i4hTyanljM4HXYtJz#x_&VL74!HDv3?(ZLihU^FcK#yT@;Mff z;E6P>GsPo3gS>0kap=y;zm#n7QADpzT#K|2ngUA@OU_>`4@AJ$*-f%sI zxVs0vn~+w4l=sXY?$Xfp&OtolpX*J@6i%5_7VDFcljv--l#e39GCmBl$*#lQ1r#ge zR6w)K1<~U8axCK{UV}D>L#sN_MrrSqe=LJbSqle72?b%#-z~m!u>d+P+FGO(sp#(> z@{M1~+Ge9x_EEznHAB~%wmHj61VV(2yY32k>geg|*)}qsj25*qTzK_8z**uB(o!C~ zu@s9m@w7?yMFS;n5c5$lO`Mnxbv8z4fJf`Ic@-P@Al~&m@x#n~;>ZOSSS9bHxrM6( zf;ZTLR(QD@e*$mfgR2kWBi@Y=c*j7-6iyMkeB%zJ?N*v!8I@GOI6$m6O|lfw%O024 z3;Z;I>v^G*@a^n9IcGw|eVclm`!GK)q{5a4Vn11?bDfQ=LMMyfRe47pFaf07Rv?n_ za`tt(%4pgkB0SAzfm93WJ>n>ofx3-qpS5y0KPe(T8n;w3pY%MM!c|^(t<<_gCVlRc6^9TGqqDcUT$@ zcN>{2Q%pO^U^Bu{{$Y3ZboV=5vJCo^!`K=W7Ou=9)0rM_(cR%KEwV1^eFYM^m=xRAoHXIW zKss6z&B<(%U-Gl6LgO<4(S9Z z8EZ+i=_9H9a0hM;UpDut3&1{=cTA&rel`|#;x#@u&f_sIo5YsoW%_k4ZxXe(Ro)cVR4RS_6p zDQL=~4U|9aj%;lMw_pnr4R53^iJ%^=jqMp6>i}0;3fwBrE{QKo zHfNL>_VFbfIuC@g-96+P9(ZMZt||Y6EAfmfS09r)R#XvbZ=LIE{;_~m9P}aVSvv;K zxLgO0QGD!?aaS$e7_z%B_~GP95KO|UDFK$(Ag?S+7Xw(s5GN2yU>#pT)*#~k6mjAm!w*Mez43ds?a z1Hy&IPYoPNe}TYw#Zb7#fraVLhpHB#;{8bi-9V)NY9Z~GyFu~1tZoSU?scVXD1xFP z3ig78+10Tdsj)$+|IOL{@*Ss`r;rxv_QJEYTE(S8R=J?QiVnjrT-3+V3?e!oAmuv7 zH;7SL37Sb-bi$hNnlCs0sLns4#UhCWV5GhJ#Ayl^7I$1Phr8g%MCGeM`@Bu16mC~t z?dgNtd)nV-t-0lbGS3InY>(*-KJO9eDbr7vFt=R4!?SK9s}<(2xvSsuf!@trPQCq@ z+z`y@D55q-^mIro#o(TAytSIZo$ZACQ`Fj~-c+!<@H6oj04XDHU$QVWZ$z!WVZcE=uO4g}kkO2fH+Z@JL(Yb384JoD0L}%OuYXE?t@%Stz6$`!8t> z*E1n%(iHEXi1Zrrp@XX+D^p)BMM{m=GqW$pzA6hAJV~)#gk)HtrzlpK9k<8qV%Yz=w_jDNtKCDFUK;I_AQvUz} zV@8wL28g$>^}J;tsosk;BB2;>!L0;s8h0p8c#K|(O&m@vn6R^fYDbOtMJ5kXnP~#Ylnxyf*Nj5352G=C7Oxgzn#3;#QRJQ7w$02RkbtR~fZIf2Ab{-gA_g zm;F%cW4c})nRQ`o!~HI3jJ%*dYSx2E33mmEIi$s{1eBMofD4|dSTQD@feY(0Ue;hd z3B6NypppCT+ANX+o`x(P`hVDa@1Q2L_YZW9paKdAA}UHziULvu=}l2lx`kc^=^#x) z4Hy+MB1MrVbwL5?NbevBB3()#^nmoxd%Y*_x|^uP{mpmg&fGixmx1Jc&w0w{JpFw7 zTK^3yi3FzRus2A(Ydd1-f3}1iv0eP+!tjd79c&C|S+W8>ku2M%B?ozOt*;~lZN2|w zm9iNqr+8;_wBq%b5&(vgmc*k~6Hn3{fpI_w1##*sof6i+H=Y#=EV;1jVR#1@!@gS$ zdeUy`%u(Os(|<`W{LZdjm3L@U{x%liKE&?8!1n^B{)U5blOxzpDuBRz zF+C1asOT(kN!D$U69Ew_l3STl$*kEx2ZtTVWWTVx3f~pg0!`*&68SDh>24(>$FJ+wipC^GJpHR z4`cvv40$ppY$3WsrvW?dM_Q@L_7trTlWy4fokb^T43jRgF+lGI)OlV8hVWq9T%YH~ zVoS5JV=TXM%JcvnTWv4^29JI^%KmuJ@Txm-ZR^Hb{?^6sNi=qlQcAv2qicuaz+3rc zpt^wCG0$e;edUrJe*>7xWQzjfPk{`bQ}C>PU%PUvkK4tesg|l4Dr}B69D+U?u8!Y^}Gck`JO4+F+s*uybCOFim17m9iXqGJtJJ6H5kR7?Yr{W*I2T z+gWZRuyP~jSojTE@!1JyKZKp#)vE&=r0d%U{EzxBy>VaAF7OyzT7_rEZYn+ram{a3LOZ;Cq10CW>XL7@K(S*xj+xJ0qJO|u0Rvj*} zL&(x!5wP!GWTDQ_OY@{?NS_@SJ_pKD1Q4RG^Ywxiz>a$lf}vFFa-DeqpuUd7ar^VF zIxHR}wccny$RNq$fUEDa1qn%}w~1yT%m@mJoVS--Qw7X3uuN$hhWJTtIpxgVzOgbB z{g9j#+ueLI`QyY+H__1%5m5?WT?zh2rtm*x*~8VZ&4B1peW5qD_72p2@?eLMO^c^= zW`Jf#8*r*V2Cd?cumM%uQEai-*V%UfAsFi+467ey-UU)F|2?yi0+y_m%Z2TQRpj$4 z#?nNJj~Ls7jE($IY-%GUO|b2EGYs3a3``}F_kQj={{c=vbAhe*BltoX{Q_HZ&s!K5 zH{23fbj9LZY0^tAeE^`hftrwEOGpb}f=anZSeGN*W&AC+Vb^)u;%+r|9w4@#3>!Vc zj@NlmQOPa}DzI$IUGIG}57{p;s>Ug}_&9*>W-l-jES@UJ^c&;hiL?o6X$)N;Fx;t9 zdYuk8$00>baxXDDTOI#SLmVM(x(El+MNh0fE)SZ8@AQ^2*=E%-n0tcq+&hww!E7F^ zv|$6Gl#*b)fCsii7KGbp(kMZp9kyz)0<>__V!NKOHIJUeWQQMtrSph^=muh5;4U_9 z=Se{qyYdR3w>T^K8wYRsOAVCLCq#Q{?zmRv@G|T~Hmq@j?K8$^g&$DV1Bs~gt3qPU zD`0MR=Lz%k^FPAo9Y7z|@B&SjzjN>!woBp;Qf-oN>Kki*T)?-#pmd|9yOhj!$mxT5 ztgR5xa6k%zXmzUREgQ74-A-7SwV@hlkLDqXSOKlh z`$s-DG=k08OafLQmQ8Ub?&o}FSBs9b3pW04s!@LhghMqsBw}^o^Vl8>>}d}4IQ6Zx(eyMp2eDaLEWj>dgFd&hm24m^Re!8e`tK*f zWe+_`3vEx+i^|LbHud9_++LA*?8M@~{tx;)T9HUUOml4YWIIFWlYici{otMK!jaZ#}F5<88xDB+GVQZE#cdc0Khj!1qiQHN19&g zq|B=&tF)gy6kigg+~MS_g$sBb|2e<#e~0@WGms^gpQz~){6<9FfBtd>e|`BhBiQO6 zGPM80Bd#U^7e-U5x8&zGCJ_23I=9Qe%>vkofdA@-)GokSh|mt+`oG^Juv;ljVAr;| z%q@L$L~%IvpB{jHHtD{7jEBpbalM=WE0sR8Gu{tr56HFt^{#i^jj|MQV*c<2A;g^} z;TQk%R|OQ!iY$WR3iN-b!(Xq3Lx6cjY_rp8{9h;oim>-F#w`wEcABmKU$o&6s1M-0 zAM3p%y>>9>aY*!91;9xPn{^+~-~P+1alaFN9|--f<3B@>|H>xD6hKikap(1)8?66@ zqPsv%5D_I=|L9J3-GABP|7T$2*BbB}I`A0&8#?fm=r?rWVg7IEz_ki~L&tCEz?BDl z`{Flr{DzJl&5z&E@f$jRLkB)!;`&PeuRsSXzN}y=-!h4yGZp;?+t%c+*Ky-gBd#wD z>S7>I6o~9a@$AKyO3`s~#?Gt8f;$X`{d<#N4uSO8!#>)e`9JD#0uEB_AP8~Oc~Su9 zZ-2m-&iFga+$>eLjFK_gq3Y`|4_1>i-F?%gl-n{RW*4qY*~AZ=`8el%d?)%7hq6}h zSp5#o$A9UkR6Y?%n~f}cet9p*jkD9Y*fmYV7 zM|`f7!!1*<)SaC%=g{YQ-2a=#056%kyc>U6Ml?>T$(do{gd_%TN3x=GJAbGiXBM^; zHg|dxwjrQ|vmp2qi@&p4i^(~TyQX?cIhs47H1=(p?8-N9@|C_Zbi(fgd&LDJzxVi* z@T68Mln4x5&{&sbsyvrcmW8{oAGH0YbaY9{R=HiBg(B_(Uh9BJ1Ez1+68?(WW~A1& z-qGMnAkL2d;!G0+{#gDGTPRuN@@X7ifl{`cARZKILys4whQA}Jzi2FJaxUa`%X=Y==h}`X#Vd}6QuIlGwIf-sJ}yV_IE>xxE9R!ACY!>U+W{ZDaRnW& z3bevfMe!m2+qCvAjJHjA)^Q)3HucW0+hK;qU%Qp+Bsy1m*fKB1GBYd$m$F2+q!z^S z<5U>c{<*qObSTXDS;BguK+@d_(o4i)1=l3;O z1BV_H77sT_5;8?=Ib}}4+8B>!PXIcWP~h{m2U|z7^#Js;p`eAk$bQ;X`)$vRQDtAi zD%^$j;_+knrgQ+ya%=_2@OL5=4O$YX?>rVSB$B3k@$D%;s0g4S{2a-h-beSgK(nyB zN@$$mS-b~XGAm~`k4Su1NPxMazdH9t5L}ZqP>ZpfJ&X&_ct6L=N?8?z1EklkEP!h* z^L^-G_(=BUASkyKn=;0G16=hGc8Y65LlN0{pQi0qX*>z~emBZ{;C0iRiqsOEyNk}? zs+hkFzz&LM_?$46gn$yWp2RwbtHvMFa-vkUb|_BxTh4umN?HKrwdk@}@#*;KwDQfW ze1-4!uBoEm`Oc^ilzR+32fFE=B9dN?$M)}eaQdxO+}wae=*$38P6SboyMC$Y8VEb1 zKxo}=Jl$7*3lVo+aw{n*aldB_OH&y?mO?O=3#TBv^WIC6Qeq<=k%qe#!LPrc>&wP51^XEN=Sk!lNS% zr}E8mi%fp}Co~zr?gD&5Xj~IAV1?R9g@wxJf(9jEt4UZ`LENAui z;H?4v0>s{fULyPPex^(U0o8TWQ6!AI5vxKORPBb}GTA>R*$wve1aQoC(~G-oaJ^a2 z#k&SIOBCob)bY+?Tol~}si(mkM2bK zZk3i9hCcNwO-xBCHUbkVlaf3Np_ zdz$cL1Ad>SNLCktpE-HE4I6(|Ht3qE-iW=VB<;u?wz_N{|iMvvw|dM4t1^`L5323lJX-! z(}P#<5|XALpx`?inE_Rl??QCpx-S1$(wx2sjOTsKxwtgiQ4qd+|KIO|X94w$!&G8^ zA-n{ac8Ce2-HBdk5vCeJib$bA>aPWgUc&K^@CE2UZ1O2Kn+mBpOK`D-pTlScMBPl( zb{)?M`X2gzLFCKWTswT|@c$rU{sKVMvm){~e_eXB<8I+60ZqN7?C|YT!u$VzXTRYC zpCZ5E0}n@j!w0_h{e};GsQe8dzu^O4B7VaMzC_^JOuyj+Us!&_2fncUwmyEt2fjr7 z{}DbmB$uX$dJ8N{7)sfwnI8FHeTHyOh`GUs$S_aMxOH9jx~ykYyZ@hu4jyV_&cDwsDY^r!J zeEP3iSAC*g#7|B!3P1Wk`UkJGkk3@qE^12 zXQ|e``oH*$2Z;_b`raS<`J|Trwz_mn2q|SS=Beoa5@X?c0H}J<|M>qB>j_ej+J%0n zV>|M{1Qo7OD&H4^ypaBv;s1>aIN^@6cO<8!v~xJ7-2R6kT-iQART(Ke!tsB6cjSAl zP!|B=UJLyY@xMf!j$8m#-FKDze~Et8vI24MOwF7BU&1V>_D!C9&-|}mJqQvv9_%!=xcfXI=lxdTlmI|M!HJK$$*nbppCA+M6 ze$<*uO<^)fhi(!X-q`Kc3_FAwE1S$5B=Txe-e*#>zMt=S1|JQL2_&6 z^aAo!4YGMsNe^y|+nQdqAbZ&?M@4r=!qP7ee=-3O+;k}jR7yMAd+gRPq*(t9(YW7% zK4#mVN_Pu`KCxc54Ds23^Rpksww%z02x~Tw)~VvKdAr|HIi@#waRrT#V_uTIwQh#a zx>U@)%{KeCl8s7F(Uhp_%uKcPp7nuOsR_S&yN6#btm=nt;Xi0p^UK1s* zEI&uAxkKMLuxhNk7x%m}EPYv>CSSzPTeCd)>WsLy4ZQY%aOp&RntM>V=un8!WMX6E znL6z%j_L64^cw!07@(;97pd>%>APd*$OZkVybk@6j6-#<#o~rSzM@Nx+4oaoAzv{Wp9YEiH@4tkh!e?aO@Ap6aDE-ux=jKlAB} z)bsH3DhEHEpe~JaGDD|POJ3uYO?qF?ZkLjvGfe+nhzl&}X_@pqdW4xPEAWDdu2xjv z>U`xU)w0s@u%>;MSNaE?o6CJ@w|G|d&aVXt!#-5iAQj7dy$>8W`;M&o`9x67)E0-F zUuaMhH9R)cw`4PH@n&Y6_fnZjWAead#I~$_%!&=>%v3lVg`69Y?O65+vV}ZosH+}2 zA3?_1RRT8C!J!kM=2}abXXTZ~5RWOuf1j`SNdKF+3pH_ zDi@BMN=ai;R0t0e@<3A|HmTNG+byzPM@reI0ul@^`IKEDO0?N*N0=~^%ZCRFiC!;U z73ckPGpu$nQW3C?#O(%z_+sT^Y(+vLTU!N@W4@XwX1Wg2v_FB zj@-6!*XB|dbFa^~C3>3kHQYgEuJ$5GrCS$Vd%R6pvvQVNQ@>-LUU{62d^0~&xxHW1RH5&T^F~l$0#cl5 zHu@6gQ+u0ve9khHktM1AmQ9Q7NZ2XXYz`&NUM$8#{261BHM;I=G(bUwx8#9`a8Itg5@f8 zbMTta4)oiwvCKZXgn7SIRJn9Xt?N{cA+_4n_@wPrp+;9NHB#aDb0N`Yb3dhO>uixP zB$uM02R1XAsIqIddn_pAfDi}?waNtKDWJ_Bp0J2SE^Qy>u$d0Y*AKGj(moDLu}7ooc5$#e68y0Ad(vDBr|dU z!0?3Th7o!Tfhtc)Y9mK*kB^5u?B>p_LaEF;8b&LkErn~Ob6?-&*87s7k zfwFbWCh3evzH}P$zc3Y6QhQ(w`O~&aID!-y52w@c3rX--jNPsN<^D=HE1IlL+qFb> zOH>MUZN01NwMUd2Mnq^V$B|XY?jv*BH9Z>AErXA|VHg(`-4!oH>iec+^X-WB)`!AY z**AxJs7(agJ(TC57cBcZoeN6X+FUb}+Lqt3lUiaJQH!q@?Y8}Q>oyCS1`k=ptNVO+ z4*g}m@Ert}E;TfOu=}8YhS>L`Mik0+xa)~8%CvWwqvhwXxF#<}=R@R+k|+h!-t3BA9wqubfsEnVk@-9*!$#Yy?$`7s=XXm+^RATC zNTxIka*vG|{~2Y)nFD>7^nlN&EU^qT*FT=5w5LRZr?FJbpAOj)WfD!xzo$@tZrNht z+D~gB^<#jE!}iI-=DSntdqFrt>2y6Zm((dTutaCCZ&YgwYQSdMw>)WPI!a_N;OdEp z3x!Ynpv>DdmBIlbS-is*0g|cBY8Ls5vDWmTRVp=>Rn?MozNB`yG391K{H(%PX29_j#Tue03;7#;x!+X{{mn{w9pG`a)+pfCvit}H@HezV0+4Bo z(_$>E#m8WSvbj)tH&<4tB|lHvTo%h^>39LdNyXr5^|5SyP{{OupI> zlMv(ePK}@}L@gG)u#-D8QyadhMg4I3Q{xNQFE20oTG!dX8H&%AwlpO9{Nq_cw2w0u z8v-aAZ@@2C{6Rc3X@!{OMp0JN1~qC2(XUZG=Vt0f_oO|DMQz$>reyCPOnP_!W_{3y2GkUSS)^=TlStxZOVN>D-wIf1#)wQS1EA0w!O6#;VbA-XA&F&NHzVrr{PdsyxGs!R{T{@L=_6kddpDZRPO-Dljq=qjBy>xx)R+;YCA5=NRD(L|27MtQfKd+7HsX~ zew#PG7Zl~D(tS5x-&nTTzu`F~**O|!>~heXqBd=Dz@hMy!t_*uU7X7;G#o+3Ch$3N zX37Xy=_R2ds-m|wsZ;9sl4r(GQft?W>4uTm)h#ypHH=%g*zQ?Yx|<|ADIspSv9zbE zM9~-K7USOTwl3P3D@jM}|OF+0y!nuc<+KaU}FyZ2(T(-k0m?L%AFIN<6hP$=S!iYVp8cguhis9so<$KiGi&pe;=QUPsePs66jHae4bFG2xTv0z zG>kH$&6R?!^;WLP})$KII2(zNRd}4hUjQ`BuLEIy@ zUkoA|F%r7Ni2Q#}Gb#N7h>S@ADnCA{ZSS+{#c7mMy*e!>WXPgMy!4dh+gmWoQs^J! z<|?<|DCqT@v_#pN87E(i31;qnzOq?q>`gKMSub}jQrWQWuHInBnF-|X<)QK~Ude-X zOfWi(os|Il@XB@KyJ5Tily2=BQ0;Qylk!pdAmiPUDD;l>ZDP84<_E8bTq^h1IxFp| z7bT)plKLDXgN4r6sv8ueD~`W=lamlMzvf-VllKY_ysiau)_83gqNB>Q}Nupc%eTfC&~Ce>S#ieI8#k# zp$O~CLPf)F)MC7Za*BeY{&H1QJitjU>JC0`4_7pk7M<~EQdCH3|FTP zoLIdUr%C)ITdO&ZRX8J~5y?JzVSS)ll=>%z7enV6C`j8q`pKSO`;4V0#WjSm; z-NZc%)LoTDEKE{d*%JL7d%N0>vCXUwRol9!D(qHwsfFY=?n_gcY&|j-UxQ4o&{Jw{ zsLmeLD|SqK4nj@EsvqPf<;4t(cf0oLMIl|C(SGe3BfbkB{Ik84j#QFYTco(@NzefOoPB#J9=LT%Y{!lV;b z>#7;qI$i2ujdU$6an$C@*p}61*7M4i{;V`(9s93ELXm@5ZyyR9X8@9yWwE4eJSZgq;D>Z zM4w3XsqMI-#F>n?yJ5az_wKbX$5V^UJ$%a^L!aH|I^%#SBzG=fZdqQq1Y<~fpL{!o zG|M``$sAo)1>ei;VTq1I@Exmu#sRC?zYtmJDInaBpmXXuskOAa$v;WeHa*F3RonLC z1(hco+=yJ}@s8V`4+XD!Y;Hd+WEppH%toClme^gWd%+rM!IArM`T9io7u+aT`WfJH zg&EAB#D#PmvVOy*BbjAh=X7DGw~=)j(}F!@hN^0Fh_<~YzL)#C`DBwQhvmjxmC>h8HDrlbt6Y%9++B&OoTo007&WI* zn$tksED#xC&UI{e?yS<3Z2xG@j6qpms?DG~m=M(hPN@g%lx|nh*$8{&kQRl9zi%+B z?=>;jSw}?bbZ54O!3q1~OPfsa>nM+*=%=bl2Kj)BW z(pB$|n>cT+(CYip<3++W+?8cdm%|Gul^D|e@%3?LO&Ysmp=K2~CzrWtMK^ne@R!wn zipgiCL{Bx!Sb1A^jx4vNPu!dy;1D2BnBLvCJyhv;3_LMuretfSYzt9AMRE0B_B!k8 zr%KTR5oFETIapT1+X>w6m9i?Lf-UL#sw+19xYouG_9H;aBYtJn=j34_?&&%_2-E@7 z*c6EbZH58{OB2>vP2mNPJ4VEqvYcWuHbD zaIAFZR-Ri#`mGO0pX=9$uQ&#X^4oTTAg!NV5zlP`O7TMI6 z0S~vx+{Nj2SF<1C{H2fi%2{R$g@vknVg+rX8>b#GEM_FRFO`a2pPnyYddPe?a;0Ct zniUwvh%nai<+rI&^%pv#%+M2s&D{aWqUf!J_q(a~GW*I}F1muL#SU6~YTE)Lf|qY@ zx~~_tU8Zo`ydB99zLEytFpi{;q!njxUflfhvzTu8r}7;DYSoS`;E$>Nz7@xY?%XJ*aK=U!SYC6b78wJPmBwazY*I;W9e{Ei{l zr(bMeinZn#iy!{LMzP<lkz$)_ zeRV6$M`Em=tTolBG+5~T>`En)D!??GLtm}XK<(W~K5BC&_n5^-{7@-!w96Eg-I)+F zC`03KzLEy(S7O`z1RR<dhmJ%cr!V%69kC7`nnFHj zkVzzMm~h>GjVeFtbd+;sZ1di4ND$p1yy%*@b)!ckAg)Kpg1Mc09N=!d0Me5s3@Y&^QZ>zSEZlaf6S?QiDq*?=CRB z*zmp5$NyP}p()kbB0h{SKI1-1sG(oD3C2>rP*&%SZG#DmWX*fOsw}wxcaMgJ7YL8= z{-YlGyh}94R!icz!#p>o4Q)->w(G9+8Mma?8-FRf8&Wpm<#0D+n#Kp&D7VXC!ZB!M zxX8UtDA)FNV+~`<^x0*GYNFkSZYLZ2ME&WOTZ}To&`(F0OD~IrME+fRT+qhiCFJpS7LRffeR54$ znh2|HbCsrEQjEtE=9Kr8a@&qkk6}*I zlu=E|f;K~1bgo4aBa9(&73%ZMj8nZmW*nsDX&iiYGR&0b4Y{wDD4BwtNlE#tz`EOX z(Tq7lj?F!28uP9`OwKVH%6Yw@K_)w?~xo)l~fvEerQh(vl480zXz#f0keup;z?ht>KtGHIE(0V*uUml z)0XNvXs&kzDdl4uRAZ;xtK^_ifT9aXK3OS|V^rX`HfpVa18;R1Qb>>fkinLWd%v*r z)EVLIj4s#E11ClnMrw*r&jtpT^vo@4aQN*lE#g6>k+Yi2xcMEe>&!jlSpOh#^tLJ{ zFm$Cn^Oakrb5g=wCqhy#x#vU1Q_DotFH8tNYSe1O@)yQ?G>i?>8PnOBOY!Hq3^0=m z)re{SwGk%qG-TDfwY?+qXqao~)wINm#n#NJ-M05I^lO8@ZhAL#3M<-BiKlHeF%Lr% zlKz-25pXx(2oZasmbw;c!6${-}kn-pzi_cRrM z)zJjvJ#U5mqM+e&H1AT*3MD_NktFz-3hS}LR7R} zMI;QrttlzV%@5D7I)A4}Nk_CY;^hWK_4%58gKmG?%EKs7xy{I2{@PjH=e9*r<|DJH z8pm`Mrg7T60piY665}HBenZ}?{PmQvtPaR@FUD%yQi-9ClCP64bUW+}cR1`KwU(CCz+13>#^yQaL)T_#_NX_gx zKpJ7y%TFGrOig1R)O2c*j2|gZ7`ZD#5t&qeYb9pWgyy@lThc!=@AUfKs24U>#JZdHP#n0-Ci!X^U_)$ zUx~fqeq*dHDj;|?q(A0FzC@h5?{ea^!Obi+!)uG88hT;t32ACoxYJ5Q$iR_kZyU;q z-*WxKp(w{av$oIev~9~lmfnEDUJ-Ogi%7D{{k*ee2@%ca!7-Pujcl4?qJ|DPq&>Bg zF^i=a{G<*Oi|eJ1&ARSKXOw)6{^kcSYx%6HO}*FCiikCOU_F(QnO!mitETTcg{3>nyFaD z!`EC0~0 z4iWL)_lSlETy<#N8>J09OOsmJr6&7ix2-Qv#cpTL;91Xfz@*H(eU2N)eEgRa-=rU$ zu185X9EMX*#`Vxy2#K$<39cDO(C6$%i@E!|wnuu4Z0ikGa)sr&SChDN=1Wd|Tt2Vz z!J+Zm@g^j(qK%Hq=*(2*;xYU1h>aeqY4Zgok>Wa}aR}yQ@%H10_>vm3x{Ag{=T57j zFGJHvp9UTZRLNO}yFqzZ(d&rqYej+?;?2v@P|les zs#4^#r=lq=xGpa{X(0R9AX3qF`D#&dvt-S}JH4<)OWJ|W++N}FDBt^yUwj%vzpQXp zpa0x2c+B+aV4h#x6DxKcfjF#Gw*6MK$YT82F9doN()fvF%gmq@PAz_>(IPinBDe95 zZSJUg|M?f9Ij6X@!y%=g%slj2h86U;Iaf9jYX!Pp&v%yu2=UsQqn}kaQjaiSF;nSx zs=rPi4xF_9p&T0Kvvn}A!pLY|4OtdcJayXMOUZrh zxp6`i-+Yt$(lq;KvWJ|eBajvc#Bp5e!gE08EXRi)XKz2-(P+FB&CG@Z+Guy}dJ+h6 zd|2xS;ZNkmd`yZ@*%L@`gKae%m*CI}>cfH$LXb@Ym4|-7Vi*GRraqyQqDT5v>6ce)k@+d(X>kYI% zcu8LEyNzH!PVeAVaa=?Y!>(aQ!_ZFtLAxDE7e|(BY)zerw~@62fp1zShuUyZGvz9vINVah@G{{9qe&-u!%!{M z%6QD`E+h$3`tf07iiF1eBB^uthz`1)Gh2{CV`=!th6_{nZnT=m=Hxd0WT02@R%v{( z%aFu)+n4ksIJ!KnP$qe}u8Ss<@SuZzlp&6AcCz=xs##o)(K}i;-Jq2_ zXjV|3=>kzvZfWyXdS|gWJ=JjJjN>}zk`;pN{Nc@kp(LpcQXZ3@APlMw)wMy?PU>cf z2?(rSv|oxl;rw<0!s)eM7n3{js#WAJzxRh{LMBM8W>?xD!69(%SwP_Yw9-t1qkq7u zHAc1}bh9Lu7xR#A)$C2uA9Erc0c}VMmaX#{eXh6ZyfrSA{<9l6FKJN>Z~K84rE0O(pT~6g%AcPI*s#Si z{LQ{((#hgA{r-uu-Hp|o_xfxp@*12xUTz^-p%%xNgNCO4g<_i}y%*kThiP=Y3c%96 zvac<%!y`D=m*mS-xGe+EpBeGzIcv?X`AH}xg`|-#uCL|aT5cXiBA?^hQbK-GdfR@z z&WFcH{#n=#AJlo^?6=G@{!7bag`@i$a-n)fHw=5Xkssx#Ps20m_6KZffBHfpw zGKm?Etgd?Jp@xao_?#;m;Xejqyr4=O{_ouE5IYP!E!UhQDLN?7qA zX2pB|@{3Co_5$1CC8!g#YKW5T%cTOa`5b!4LkRZ?`D8K4*UuRo*Jk!R%?4iZ;MObk zPk1oyY{|Lq(3WE&oYF_ZAnf^uK3sU5!o`g@<9Sa-kIb$XTsTd?4sZ83(laXL`wLkH z>=0m`sXM;aRwYOOYNMDAHmEoqysIxN;eB7UP|7O?};UjBT!Hh~Y)TvMfW_Nxd)$7cFY|1-OS2I{smem?Y2j5A^|-rGe!P z5arr&XMV|{aI`doP@_sa~upoFOU;HyOfvjvr> zJyyzKhfd>_snOAdRCzq<8GUsecl_K>MJfnH=PtP28oV^IyDd$2<94iv_E;ObB8h)b zGKHf@Q8Sx@zDl&p5On@H58`BStxgTnnXe3wak!|?AvdXg$)Pvy@{2!#Nh=V^l1(+SVgT z=Vd9PGaa#ek1otl%%egob>i)Mg4|`iThb=5F3H}j_)~v=a{7csOTkPF7jKWTA9FH7 zOHjY^=zF$aQmH~W2(HUSZ9eq#+*~G8_Yn)o#XAXc^bsELjtKf$Y$rc zp=<=aT1h?Lqkkq;T&L24k58VOy5n`(ZRZ{eQC-)qAy8-JCex>SBfEB>kV+79WT>#_ zMEmoo;9DsXMy08$G!u@Si^Y=T;~ke$pPKhZ=B={oijK>TZ(c8aES@v_0EL-3h(N*2=uszi!klMv zt^g=jY1$qI!OGN^E7O(Mf@^}WdfB*zxSH1_GzzP?#CX$ujJ8v|WinrnoDXuS9opuz z&A76C<#XXsKT2h`6S1Zt-q9OgX|X53XJjgN&ojK-oF*t3(mFSX3mmv7@i#w5Uj)+E zz+W0ARLrXb`?z)i!Odn~9n3D^%CZ){fA3Q;%q0l%^W^ak5W2m%G^D# zLY$=|YMZi@wwyh+y3Q&Ql-Ks^p{gfTbW^cj5 zb9x5F+$$2I?OdW);?8s3!#^78++$g&psVho-Cy9dt2bg}f(|zS(ipgJDx!NlQZkb1 zSoI`Jr<+C@SYY|bZ+YDB|I_E8l3aT|`R~7i8T|sTqwF!o zH|A9CYh6y4VHtOPU{5uX{m{NFmdf#3E5j+%Y*hBjdP)P6^KvsK{{0#19O<$$&F?9VW5z(UJ@oSVbC2^*i}74>H6=UknR?} z0)r3kOFk&X>1Q37@SvhxRn$h+R_`*+=^}318|W~EHA`Z8^wa%m=~QZUNG;|ih5b2v zFPz+BRNU4#%4xv?!>LDMz2NhrM3+1<#DOE%=MZDN3xyo#T=rZ0)_n^){azaVXQ=_j zUW}TnBrJZa>_iMwP0NEyJl*wxN*0ig9CtN>i3@>DF6guATa+O;e|Fi7bKQ|e(x0X0 z{A|^w&0X+RXcsew`>O=kb!x=gi5z#2o}dKR)j1AZ{SsaA3|&`q@|EC4a`S~kzhgXj zHI+vWfsE8?^bh?g-y7p05oNZaYfwZmTVkGmagGdm-jvH;pC&y|Z3?;LeD5>JnVP6p zoJ2t6Nst9@K9Es;=9`5?!k=vo3mxHYOTj7MaOvz0S)DpggYd zGJ53_4P6fE?wI=7cJmW_vL#T}5|fw`M=nhe`QXr8)}kq-56f%v{{jL-YZ|(sZcOxq zMKDLlFj04EsVPT!$;_f zYjWJxE7PCMRA$(AkAF);YPCGURF5@;N!o)Y9ilX2;|1 z8datC22_21e3snDYIU_A&vV8=g;GyAmfnk;bc)(AHS$dXnflaEuq^fg4%`Fu7{D|- zd~W;{(;h>zPYv9pDvfYzeAGU>_{_Dj@&ck!!IZp$E52;q9;M$UYHy!9C2QN!BwuM&DFOU0_@QIQ$F{&}|Oa)chs;o^+R zKAJ7lpj)LC5(3weCfb;~{kJ>|OT$*uY!4Ki^IbQsi++sLFs7e@?Nn-CyeIHeK>BA3 zA=y$ep1dT=1Spr*BdDSxy7f%tRT`FVf$I!tcXLono05gPKAVl`lhYQSU8`i)#K_y< ztuFJnW4IUos`b7U4MX>7Tjdxi=j2+6|3Z_hCx12TxVTMEr64F-AU#g5*jlXfAxovK zuv36yd9+c`)s7=A3~7=1p+)yqupU!Vw`0}&*ED^sEws8|zpT$1JwX9SNULRNem-e@XpMG9#Ksp?#UUb(i ze8BFWB1vfzSlL3Y)p>9>%z5)qL@hJ@s}*B|8&n2fGyZ+T%<{U*4+SZB7aCBzRbL*(%NTj#Pb`2=)?~ z5juYsuvW^PO&){g3Zc%)L~(0K{gkVaZCZsG1I5k&4d$V`*Yvv~CPrnXRud4;+`B?k2NU~kdZ8mw2A_2<9(o*gFQ)By zyUdaGs~$xh+4vrULF>vmSaQ7L-HX3k@;__;@k5gbNHA=cUQzv3nJ^*kj^hIU`}h#+ zuWt%!{Ee@))^{UJ0yE5DfF6QpC+gKKyd*wqP=713z z3Ad-;u4I##ji-a93=+!u)Ww!@&F7g~;FcYK3sU@ZuIsNs&Xld+K-08DId2|SdwLit zX=5Q%bZvH^{f}b2l3V;G>z5EgU!{G~#2;Ayz1n9|Idu=xFV{@+uLNn-_dRs>n=C)Z ziBrgeW1Rf;{IXVH*(>^>F5$oKpY?wIaQI^XjHi&dPgG?Xx$uy`lJ$m>F8&mC{9o(4 zgW(JfpytT#J2At1>`hvAv<@Z4U1+he{t^BI@V=LcEufs>(03>Fuz9w0%R)xbqOIH(8uFKxDBeZuMaWZCKLU(3GW#6c6R2c(uayz&&V+$S zg1I=8ox~Z(cmB>Uh&^-qXdz=s&6Jn@JwZ+OAB5x|T9tKRr*1^F-Y38=q9AE~66l4j z^#Qx?PYnc0TB4bv|H#;N3V&4ocMmSR3L(oUL;Ic(-Kv=KI;G>Wmq2|6XFA{oaY>#c zp%7ZxYvRKWw3R^ZPw<+F4iX9;jTVCEmFO`|;CRCSO3B|uY4U)3qKKQPyf#Y*k_p^P z0>YHQjvurnII@_GBi;0?6yLU1VI$y7rC-1n(GuC?-N5smi0pzyK*%!4Vn75sErpo? zPhWr@dhO3-(^-VT?b%KJ>tC5FG)-zqfCqJ0nmLy~XeX3Hh?a!d7h_kpZhuOHK=MjT z5`fdvpolv0&dUc;9omrPuQ;-t8%>CvM>4_Ecpo0a%MpApZ!gH|zF_rAd#Gq8mRkstHJwb95;{JSJ_w-d~%ck ztA?dP1A?12TN0tQ@d!fZbFfPr8E${4yfZey&tT^OoXZ>gLAY?1%84k`o_t`sT)~o0 z7s0wStIFa4JMTWN_a^i($|I~i++X+Eq0%bzgS!2Ls_BzJpMpWwWD92{-=Bv>tZYpa zUu7eo%=!+2ZGOO(C>=pIYy9v8oVAWdNcA~h=q`b+KQETfWZe02yCC9Jz|oEMzI}$! z;tnfErvcbqZIY(gY=yXpvJiTrA?Uyn5|AZaQq6O^38_Qb2trDy_8`Xz2Y5$lldC=LI=^k6@O`Gj!8u;L10~2sDu@WCKIR&29-0U zlG#Y_7Z4|ZxH_?Phb+9U#2QpleGDvzSW2pFXI!N|Qj^REo(iWnv|cCQMY^R~yI zR{bblIDY{#%9gK0qstfZ-O1iEMr1^Nn+1T~X8OtKKblkifrOuZD7!@*_w}fWcX3uA zbfQWDX3&lrh|myXhCMS+%2zm%5%hdRKu{Lsq;?sUrh2szf)jO8xETfun{VuNoZXkq!83+kTHX|k4IaxTewY9!r1Z(bejCa!zq_>an|DFH*?HdTP=affB%Z&o zx?D`&KxmAVR4|ZdE*fD%4t)J|Z%kWvc94xcCRKB9f{7xm@7?vpCGGK9c4Yq2OCnpR>@2!@+Z!BeKN8bPlk zFNYJh|DH7J`>-yO?a>ILosEq@;GPFNKywi=Gu#wR>V4SxXgdaBhL=lva`^qu3zExIVebED(mo$<^!|6R zq)r7x(~UIR@h;5WBZI^Cj{fb`|Arzvu%V4CU-fJ zi$`)xPufkH424?t=M58HRDAO2$E&2F2oG78{-OG~^$z3Kh)EUgYGn++3FD8>59*w$ z%r=R!uZK!Eub8YS#D%Rdij_Fj-n&5iqpR4zbR7b_B!{c^4(k*VCSLh$*2FrM-D|Y( z3!PkQI=~BwI=*uEroyWVR?I48e|dV{|6Eypicq-GL#?qSFf<^Q(P*G}Yee|_yq9oD zRmsr}vAsKTO^hlY&)Fpb-cLS`DK*-d%4nip^op|*do#qI5mvXVF7EfMjOedz7-39U zQeTp=Wkt0d{;V_1w3bCGP<&K?XgdG5E$^Nvqoy4Ns#zHnI6 z-G{$jlX%4u$*l z9W)l2uMzv*6RCe7e$P7mh_8}5%%Ve~NnmTK&8cuOZgS~I50N$pCQ1GMh``kyn`-<> z7rZUc*l)9i`O0QrisSE7h3qqrZ$3Y)_PCZgZqx84pXR;OHapKvtQUhqp?+L^=erFv z$h$PX1XsEpR{O$z%gXoFKX5eLF%N&O&HuPs`EwXb21kKyp49+vNujk@b>{usbaqyR z#_hS=CYznn_xXc7&IL+~YO!5=hPwU5i>&jCS09%*j;Ds4p^e+^1tkNsbl;Iv*C?e= z`J?W(I=4DGbr!j|J(^vqp;2LASySz>$Q}YB^8fh{ zBYGHKJjUd8W&^W_a6i5C-~SC##>>PtlDu&;>OYa!ZfY6($BtkB^M`+b6)i@8_dJCko5I2@7fGqa=^uW-htP&eis8^$z0ZzEg&WOL#iJ8tM}oNZ zC0-^qciWxiKegj?KTg1Zej!pzTP ztXgDiuaK=iu;W=U_T=ymzC&I|%ytdLLn*?jFW(Se;wj9qaHu}tB()yi^ZEOSd@YNq z-kx89JFOplbJyei4W6Z2io72OcYTfozjy(j$Q=VAATmWqSWfpCp4v`BeM{3xX?$bD z@cY|2J5L`^aLv6IePo!4?Jqn0pBXTff!6{W331dTew-3=5&Ekbcwy+2e59z`vo~`Z z$wcnz-2Y`B$*W(BjBeYqpJu3dcYD8y@>8A~on3buhDkiSk4k0t6DdX5E>A48%B5U6 z%T>2GPf$k3{qKG8FV9v<_=bE~2>$iJy%gsi>~HKekm>^jgdTbGmoJd+95!Z@xRx4; zEFwRq<2CC30NysYYj5oOwmMB@Pl+4=mhEik4E;RF=B1}8#)2a|6x znf-!{^Uk)9m?@L&t(~*5`|tN)*boL?X&Rev&)z%OG)&obY1jTC!642Sk1751Joh7{ zUmfRG^>jTn`DJ7@@Z!#o?HnwA#Wv59ua&4}V9|+=d9bz$PJMM&yUe@$NCemQ?9pzH zo!1h%OEl4*Xr-VPp(4$7^vQqOO3or3#HSc47;fCSp`_(-N^IBTm&Q9+Lz=wfap;YS z^*U3E#Nidxw{BXhXB764ob&wo33lH21j%(m5AIXj`;v|)xLh`hBHr9(m#<9-Sm<>^ zLJsV5aM5?^7bL4#Pivwj+pS;jTGn0HfN%2{$VnK&l;R{p_vUe)(AgOyd{n@uwO7-1 zn|2r!rjRNgoYq#gFl|%yWT^VWu8IEXTk?AmrlT&=4iDHz9d}I49!&jya*_I%7nQE= zoV45NBJEhT-L0odLBkry4(^=LoqviqIn!H%Aq~6(g%UGj?twceQX6QD2iSvSR57;7AFu8D_3m#mArzO-j_Fjm?zlRJ>kPcRluWQ|ie-n%N{J9|dZ#;|6tg7$ z{ls9%F@6@49et;@_+IR|G67L56O8*+F4?P{hWOeRqxdFUqx|Vma?Ey~gOf^Vmv(!L6_u3+uceQW2eI}@>>;vY-pnSNnGHglx2{yf(^$3uRKd~I93 z>UAZX=*~I29R>0D(lFOwkVgXgP}O)Q&m(+4A>~rj;568yDM5(}dB<>|7y) zEDZmHQ0COp9T$-bAbgV;d_;t;-rcWg*MN4K1y}$UqP*)DtZcIF<4-0xh)?JSyv^drfl@}pi4H0#3t%b3Z}rKxCX zRHhwd+qs_Pw~OGypMrNAg~&S5M4a)%n>TkF*Y364gHc8@$)zaA9Ou?E2uxhmRNXOK zB9RDqr|zd)ep@?j0wEP)zqSxeJ!q7EZ^s9AEY^SKW{^QDS&{p@+W63Q>K)Hc?@LHT zE|X9rzv~J(szJKvI4T|c9e@4vbC{yk%byc$PH+N12`Rxpm(dV(qwnCDp|21Wx zK@M)a@vWC$VE^&im>$BGA>NvMZ?O>?Qd zu5sry{4WMY|2hDSwBoZo>3_fN&+ELyzhhjepy+EpbY9@U?86`J;a`R<0#a*o3Ny_e zi}B03_%DMZAVMgD-?%DVI7Rc{Z0lF{w)1DtNT67$f0ec4x&K!a_kV`<|9&<9&#?YK znArar*8dsS5038t2Of)sYN1< zy5!Uxv^77zB$+N3{V`?OX?_bptvwJDRIEq-%N}OZBt#6-npQJpPiOb`&^c{tkWzDG z^7dOtMO?G0JTxC>H}eSYWHIk`zog%3dyClcevz$3+y~wPd+*jg54P5qLwiFwHTM!! z#5diZ`4hKeLSf#$dq;1bQ+3i#K3Or#;7hG9kA4T+2|?RThwZIZxBTAVDY>>-@z8LJ zb7BD^drTV??;dBEEe5#qr- zma|2xif#zxyMw3%>9YlEUxN)>-rYcLyFvYK2oY4-2HmYaS^CtQDJ@9~7kiBgrd{SV zy0o0WujvL0Sfx0ubnEhfO%7Ul5tCB6CYN#j%P1oT@w55)TJ6DKWsuR*cmn0UZkWwS z{pt@-_VsS94$QXm%7nuGcqTiNm122vYAjd!tkjg=Yt@`vHQPrBZwsVa=UG1MJf3*t z@2M$O6Tu|IX9x7%Xf3-w0OCcBdby~Gk=U1A#wRbPmZxM*7B%vqC!UOblzCl zhv^CmNitbE`gvbD)%3V<$Da4dn^#spnOJT--bXo_EjsqO;Wh;oMadcf6mhbCwdZ7 zi}e7qKQJBVhV92|_aolMWtjQ#f41mU>4>>UJ=W2gs%9)`yR<({uCdYlAJh5gtJ}el zH)pnRUHE&|{z+>^81~axeKu))zQ1xK!TY!wyR&?rsrKv|_3SN{l8I>TrRkiY#Zpc?*Zp0{;hf6 zbD@7W*k50X1QFN9+1UTOrUKy6Ty)_6E!r=F+g}@km0nP>7jCp2CLnr8T0x=wD$aN* z+2s7+b0%^LV=b&(*(=<`lXnQCMFcpCagpuv`W-=BLk=Fs- zigr%FZDMEU*ZM%sC;JYqOx)X9>HlYAVwRww7E3)JwF`In)7^}wi&S~iiZkA9WpmqlPEi&!&{kR$J6E`y3wYa4!7le>OdRkTW(QSt*C3=Ux2jd>$JMsg=%c5#NJV zFOO-0VYqWYY^J}aw55M>-%0BT@sP_cDY?T48E&B-Ct~1&1y1mr$BPGC_@J0=KyvRb z;Rzmtv=`@#yE>8-Mz~w2a|Vn6rd*r|PLSBzSj_?8nU6PaHT(rfezoJqG_S52|F1k` zv>F~hdDTt$_uT(ruVIMs=UemH5ZBDKx2;>oy+oKXR{b_{p9IsLP!<%&|Ne}~1o8K8 zBZq+VTrJ$In)^5sI7us2hmq+|#SV6pVAJ(V89EKav~Rx?pNk+RVBYME6%S6D33PEU zvRloTQ?)tE%21s&Q>ZyoFq5BHv_6q&`SFK?w?A?DaYUk0TH*buo0eP4 zop#F&*BNQ+?3TudvY6eT={5v?Y4?}-%MAY+{~kgdH2?sPn%TcV+~{As6LRZ!WP-}# z(o7)i>jBi7^V2zc$EOnvG}NX%l;!v-xx&U zbcm*GUtv*=V&sO#xXwg8e}dmafX;!l}i$DO92?d%<#tQW4Mmd==fkcCjM!*$p14(uS1-%wJK%}+}XhH1^m$mi@5^sw?es*kf@d6&uGe}bz92h0__N+ zfDSyG;b`@nj%>qTEq9jOSGpTR1;G8c z8vxu*;`QPAejf{r&EnVYtNUoRY}rrR&wbdDOm(U`^{K*bn<<9s^656b{xY2CyMvML zClNwUaG5$(cXE7uHO%58FyQyP;SL=_AOt`eJ~sh=_gj_lcMZmH`}bDct+dqVRSjlK zwjHHxwkA_cvcsLecgrR#B9#ZPUu7rW51aL2Lojbn(Mk_%B;o~R3x~?`@^81mmXWhX zXs?egHVF?b2@H4~K3$u6jY~lIf6TtmdgN83M$n~44+?zBZ=nfOGxp0e;x$uR*o#5tyO#OU%#aRD?GfaP_mUx{2OoNKsVQ69e8* zk60|ZkoeV95T-x1RLOk!hSp)JCDP$8hjPC8qZ+!klbBp0)Y@PTV>p?SN%C#oznt41 zj1PiMKCN_pbvC?}^B5WCI&mZ47>&Tl%ZtORDp$+#LHna8?FqTk1VAD?zySD83{^sIm$aErd}7WlD_C_lF-KJ|Ks@)-Jg}YO%Yx?GnvS~qWN$~lg}#1IX2N#u z_M}Z6drlW5NKfb=d}d|Lb;(Ycz~psMeiVzw&4VaFe{Rz}+39PbZchNGrU7{VloVza zRoGQ0u2`pa1_bqjO^^MH(+EU@(@-$Z250b-PkFtM5HSoCJx4;|M0ADnetHgt_Z8-g zkA%N}i^}MQur0<&*S6b8t23RkQ3J5$O$=q*KkX$ust(&vPk9n)C3|)S{3&BDbE)M_ z-dK+twdzr#x#t_M4(L6TEw|{n(HJGj>g79qIFzyt0-?rWi_!deJjrou{VKTx50H}2 z+YO^O(2o{81!mHsJo)vaHrm$8Ax4Kb4qX(=F7ttO;2A!UU{MK2@*RjDxgVs}bEh%& zz&#KS)U%xiGi=Js12@=pgIYz)wy0(I(D8|lsbqxxjQF!-ze)+|U#k!hlY1M&lrQps zVCkMt6WTp#+E+!c3K7RsMlU=n*;-K|mqk6~Y?;%*O*4sDZ%pU(f{WBYUpOalxk%{fI3Y)DkEQdZ;cGQF%L36* zN4wjXpKN=OTnbsP>B;x8WwPYO7$ik1`R3`}8M=1MlCf=14hcO(P^G98bm#{V{IORa zc4guESF<=Tq4jEf5}JC~qhLBa823Obvx1n%pi3QZd7X<=)b-M5TN-$J+2#+}CUuuM zIpJM?#u!vhN9y~!-yHmD6cC0K@EZ{A$nz^=`t?1+FLnMo5SsbT@A4VN021zm2Z0@Xwi!hyi9YrGR6l?8rSTNOJR!Hov>^;_m0*e&aZ?zz5*gvy;_I z`tu_3RRHY5@hQ3ewEYgNDt`uH%P-sCXSWO2TbQ#;3vCv~(4r1Ku;Cs$tS`;uf~3On zVF&MwJEl8R!*Le(cJV!b76;Xa;E40KzFiRb+h6{(P8c!-4V3zgi5XsW!u{qJE5^lZ zAI-T!6YmqZoUJh3{fP&pOH!!=3i))U|y%rE^2&hTs@yJl~w8hF7IS+^kJBz7qQU z`E!Hjh^wlJV&FDzzC1egFJ7`6zep-h*}q(*YlQp>E$ufwY1VZ8-wtfs=q4DOSFV}@ z;E?Z6_fs`rdMmx{o&PASSpY6u(t9JQQ` zcC}7J5Ts(u)? z4ro1C|7izL@<(dd`E^5KG_M;@t!Cc>DRqvp zljn-~lf4I$K%dz{WkR>x^X+%tPh;MERrNz=jW=U2h2)jPT+%vJ%ec0xrJO(H1R!Hh zwqJX1QriesnF`H^1*jm(6X`-XKJpIX$l@|7Y|3^A%=^^qVVDxZO;i z__=o!v7UgZh|iq2aW8u%r)=VutWuCY+Gnr!l!(%(&wjDtNw7qOYKG3MEikog*g!8? zQ7OvZ6Q2?^0RqSZtJ!O&09XRMO8%u0GvPfc46oJG>uD;9p7ouVZ%$P(tp>|S|hVXIX(*E@8{juX0_iD+sB z0^}U)ozeERxz43t4b`k!P=5i`^*W`4DZW6BdEe^jppZ$!2ac`B+ zD=NSBNa>inmYphA-QYEJY?F={{;`*=Ym|@9kZp@S8jzP?T0On9$U%^%)DP`*Pxw{N zpmj3>5||E%CC#tc<T)lU8u>~OGAuR(_Rr<54ssFL}Q zCt?T0>+Vjr#=MXtuHTp~5stgPlnZ%fy-_|cR$UeQJpOS96ryMy;f;w?M^j=4Vjd>gz{@B-Z;AHz%3mWN%D^R6MJ|qkku*$|~3;KPKD20&H+uUjSMcP%(jv3z$tWD;^IWrXW2fo%Z&0L)hZDQ>zqxjL ztPbXHH}RQX6`hkkx%pzKKelT;_I1ihHDYaZvi6bqJ!}nXBFIQQ1}QL;JCY<5C(&4Y z?(8iNM;b~gq1nPkmx)IsRbDwxTa#*6zg<>o=bz0RJJ|~W|2+w9@7X5)$)tG9wmAPe z8+TEY=Ef)RV8h=WtUG2@&Guj_;H|#qQ}O| z7p0;+dsSl^mL*+2e5kzRg;k)C@Qp+t)hnK&1=GDb=f86%t06D7EM7?pHE+H1LHBi< zP>g@k=0Y9kAEuQK=opT$=RNrz*W@3HQJXWRj4Bnrl2=<+joo@4r#-P1I~qbcBLwiT zc*%+Ny~%f-ZgyJRdf6_u+L_O!2Sm}qZ+5+gImGCA zGegh75HCsXqBK%^Ez=aY%;ceRccso06+?Ic(;2_8RLv^StR|ricsG*PJ_in(E7p%| z3LZG7mavZty8W^$bOIP=ah6l_PSGMub79fMkqB^;{C1ETj3@{u6l0I$Y}p>5{lxUj8CWN;uYn`u z-*4e+zNq>M5{C$uS}6G_FAFa0aLbisNQ)T&S>b18QPNWV$r5c@?(CsM6G1UMDSqPTK3cS9AGAw($JYt87Qy z2I^|OWVp~(;xDarN7fX4j~o)1N*j!={kU#04=pTTH#c7Ct1ah`_Z3(@Oy=LCoZ5BIsl;CoUmOY5&CJULb}3#uwrL!j=KpNWCetQ* zqZl9ESquskQ)ae74eX+aORzm;#Yb6Yie8S5krkn)YMH;)E|UnwIe)Gr<59qOaSr0t zrE`Xe68EB2O0OtV4xBOLrC)eX%qHbrNysC|8M=&L3dZPB>SLi+mSKa*&#f1~HoV|# zR5C8o9)Oaj{)=LScL^HpDJxHi*nW9}x3N)ZV=B7b{j9e6a5zhzSSWw{WVBLpB8x0k zwb`Ne3pJd7P@T;=+_VaTf()A!fAE` zD-Q%)dQ@2LoJNh|)2P7jcY0Sl{A16y)P zK{Syqm{Lf%xI-0NpX4kjpfI{pI24j?F>h;QVEZ68=jrql>}{Q4zH#j3c)s-|l3wa? z?ff(h>z;S3-PWRKg?hy^CC{AY*H=~OO9LdCDTWijLo z`@JUd{4c9~Q@q@9M(|wy{(th`zrUh?LMYUJ2whXvZja^oW>A+!>o?}To z`+!hxLveEZ2eHHoF4kX+t);>pjKH=wK&qt!rKTar;n50Z)M4=|8e%c77JTCxeY811 zAJGP8*p%GILuJkLl+UHnrRF?EDHiq?+-S*rR*N=8k|CEl6CN>rO2Jx5UYU38bY#p* z-%d_K+yv%20}mCNlhstlj^%pJV&{UV2R*Uxa3)5;P2Y^S?AG>=z@~e$uJy>Ksv5S% zNgfI!Js*B_{beBVM9U}HE|W9D8V+n9-V$6RNU7b@=SFE7q#?a zf@AzdEpSjQ(S00k8w;#s^Cu;!I!dE@V1OhHJl zx%~w@Z1{CT&P`~0t;s1!f=JdPPmG7S6BTMU-WkNP}BQYCai=rsDTb}TpJ6e+zFI5jrRKT*c8qPuL%L8;Mo@B(`IJTwG zlnRVaol)WZ%|vL^x|yQ_O*MnL??&$Bmw&C~ z{GuZ?#u`I;gEok%mDRyoCT^~35AP#>MCbTT!}~@gj{>VtB#UaG5gLn1zje~40;PR1 z*bp4lK|+4SXPu}}=r&wgE!h@45!@JP$U?aUTS~#7L*+ex9F%cF))KL&UXD>5HFbOV zwsP(OJq!6~JoCa!ZMJnzJV<`z<10;$PD1nz@yxpcqUW*0pYe5;VYFSOiyWjh2C|-U zEi2QTi%mHoH|HC{^tYPbFSV-hIBfaZq{>&-rzPCN{(elYLE*sJf+(RXZ}WZR0K`6? z?emCBU5(hDj34t(RQwEC^Z2rzcwuksb}S(vpy;{vj*Z*rg0UMDxo>CgmIZb(7P;ff z7*lTUX*ug7Q0KrAJr9Hb+UQF9_?(*KS`4H-)%s18?_}M+%8Q57LA^$$KOeq2X*Pu? zu}dHg3}2l;@Drc{rKKZ&gEdvXQuq}g8!Zx{+Z5J#-+Z{(coEkdb?eZ;(6~P_LHKKT zi80jAWR^3;~R;(?epQBJSjX8;V`j(z1I`D`cN5cO%MN{5G2LJvv=% zBNHnAu8U1qyjv!`TG%oUr@i9wGWZ-vKLIV2kNDSk-^3!g5LJ@FJPjb3QTJw)%*G?4 zvPtr9svmcxYu>`w5N{n-@IloPiqB@g=!?$r&tusYGxzs7*0_c8Td=HY-Yr?}Zi1@& zT%mZ8E}qRMNlm@W2Q6Wj+z5a}@}0Y^XaZGu7KCu_1WxJng*%L)THrzMRZ87?6}e{{ z56(V&I!Ad##^Rbv1!w9Y~+i zjTFDYwaz4kw!BnGCqgq0(Y~cn=slZOqdYDVC>y5%%;%y#J^To0PhPb8{iD|zQbA`!gnkMEiuOt| z);UV=GWKL1=yHhr=UpR)1Mjh{AB3XYvgc7;u6vMxmC;-Xo;x@AzPT3b?P0;D0?qGvo`J4fZbf@6DWbnTbQ7htqG}c#FJn8G*)a#0NhK)1o7k zt0)>c14S}sg<6{=MM}<(XC*a_*&3c#%&cBoQ|2Z1L4N9ir+d zeWZiZfDZC;Jo$OTVdf}sPPkQmxwhE~-ad2h#1nPDve*SUuj)bA6)7=_U&Wa$LV))G z-#J8UKGl^T8{ul)_ACl}DaQMHd5}aYqw~>oKH+sJhimObsxQ~!uxV87IB1hSn13WPXRy0g-);IYGyV-A-YfmZo{`$K`&f1 z@S)vhFoY!-cLvlw$j2y=miA9$K#*mZY)u=dq}XI^;`IxVNLV*n0bz9&nfRw_hUTN< zeT`;_b3tK@m5fhVE6kt`I<_h`eZJ$%f*u9kQ1tptfbgVs!o(Zw z4#6>bX{RCdfZ=V z6Ek~vqbZ-sgTs8jmNnH?_K%L)i1j{cYIAEmt}&pre5C|nsUqQvs-Ob+V7bJ{P8ZD4 zGo-g35^({^zDjKV;e?yMia7Pvh!{1I5-*&~&2_dhJvhk03%7cWw%}8~IqnG25tT_? zmQBkI>dIwa;8odv)c{kSBCeqe%jeXrSMJBvGwq!(zcUGuC@^reyp3;Pnm2Auj8D zyU>%LZ(Q^CtY!HDULte`rPvZgRaJsm(T8{lO?@om{Cj+rD9)(W4p{V=A^jS zuUSqZ-H&K4H+#ml*vatPWT^GBelX~FMOg>G8k&()(eNYC^)focGjGj^4`kwR^;@FYUj!0a_3j>4JWC2^}=gSEGhhLNiD<@ydO>&~ar+o<$_OGhMQ6QOS4B zG#$6K32+D*VIFoVDx!oi9$}7EG%;!KXjMbwx^kGuQ`6EUWz@wmQ2@`|xF5RbmFVnS z=82%5SV*pDywIXpv{#us;?Y_r!?maK zO<{uUNTe(3D!%{wYQQ|mK9{@gKdj&eN=nelitTw${8u;N;KVGDd+}|on@iD)BE{`p z9wP2DTZcYuGrNgCS{@{tAjFWL#(DRAo1N1s>OHq_saKXQqBg^)Y9UYwTyI6+;5Zc4cnuMd)E@U3gNuisvx=eTq*cW4Fw(MaD#|8?$LA-A zB2st&)f>!vJ3%oE$_9#;1rI6_os=%vy8_dh-Wh612PqgOUS=`nsxccdb%l_1qB3aqG*8nl6&`mN|Wv8Bl@c z#s;o@!7|~qsC`HpWU2U#h${xISXOlJ5RYny~0n~;xDk8IN2 zL!1b4KrURvEJYH^h!C>{&c_Eu-7`s>dz`~`GJ`FOraweYZ#ELjflI)w;Kg!F9}MvwNoObJ!%=xc&G4b z>3@bSSAcg5JW@AvT*%w(2yB_8Ek~}WJ9e-=PLgiSUejWZge(t>tU4~xvxPkvTdVo( zQgZM^YtEh()Qy`OnQ5_UuO~S@vWr=|3xPasp(0*QI1+}`r2A3^-#oj-TqL<>(5g-_%c4n<*TuHG4{-B zg_YMNoj9;n`p2P{maE~b%%4)nsMs?I`Jb{{`+B|(`M4zLm$?3@m>Vs!LXre+7%T&p z$@L9#Rg5Z3RdIq4u7&!>y&qrz(QjH3c!=!*%^z}g;G%Sv;m2JE6a2@)rotv%TIb^b zRk7?za>=mglTB+$3gI@S@r+l@&=#hlqlt)@*o7N)k_70BYao>m%&r-LbFBI`LG9fS zXGJ+#uX^P~Nzi}dQEosxRAo8x__O*<$!Kd2mSSS1bn@}SjC#iEkXoDg{9tT(eviOx zp{D6=tF~v$_?DD>UT>KZC~eJS3B}tv=!QH4)m#Zm7(^MLz*|4R567mKc=6uz8R^hnE84R7|*to zkH3Q_Y0#Y!YFd`x2$ zt^LEru_j)$HJLsLMo5)tI=LKl9=myz?-lzpuLW|B9K-DHa|ULrsWr_6@&T_)L0$I1 zwkReMm+`L2WD3u!byBwSq^N2lwhMZIO`e!2TVO-Pzn#IC-@wb5H;ShEnyZdh@?p3> zU#0eJo~2H(7Iaw0#o?1)Uk!0f+Mmk;CIf<1wRMEucK0uZL0sn|C>mARy9d3nwT0h? zU*Ic7H>0}*7yypWWF8_llNp3eas_+RXM9S{Zkk(0DjHIXMh56G6MtNuV$3THA_|+P*ZwaKbAZ#ShkW3;5 zMQ1v$EzbR;{$3k?Bw8i{UL{rnF|r;~-hWKI*UGkxP?ibV($9@J>AZ z8eZz&W^%9H)}rN)ED+H4JA(VT?!K%#i;EkFv2*{1a^z=F+O!CP7ZMX=#u>&VhL7on95xHVb1`97HOgAi){g~C z(8N5lX0(!!((B|yOg-O0-oIMl$#}dvPBeOTFK(Ru^~}KHk`12P%>lK7TjQOk4Uncw5*pV3K@abkoAp(aP{97HSu!$)P2s%CUtr_nG9?%*#$eOS6eBkWj+Cs<>s4HwL~7<|R| zzA&i*D)9L6BdvPa86Pik^b>m)6FeSAiKs=jnG6Bi@TW@obnun00h%GbHqbmI-rm5~ zZO$hC^?H|BBX^*oVe#764ru3UW}swgd-fqv4|^nT&Q;>fL4ADIE`_}KMjb24sWJ2w zfn*7Aal)SRHGoRUI3IToP#gLB257CS#B<-yoXb@a)xRyGEA|h9*@&86)pUS3Wu&Gx z=H4`j1(s+%9hr~7F<5Bw_mVA-Io_&=w;(Q2H)_cfTD`^-zshH+w+m^aL`~(QG z&-?PsbOnHeHcU*;UI+`|h>uc=?ExyyJ3-vWP!|Aj)veaPJF|B{MtLW<+(E4zV_0e~zietS? zEXwSm!%|b?R%DdcA07Z+W=*G+ALPu5Bu+?@#j8kyT_ga$C+pK;p~mJ%5aRn+UhpLm za$<{DVL>XUygi8%a;vWwE>1K@K0zXpZYew#6QAg5j`c8ClA;?F+Ue29*k#)`Y=IFp zD03xA&mXPdGA>${CHEl{@MB4`0?_mE82B zw5LZxzRiv02#JULs~WBxXO6Cj+7_JAx^l$bae(U#$7Auhcp-xRDXqV#m5*ES{`erR za3KetJYl5O_|fQyEDn|kZ2S6gV;Ol~mBklU9NI-C90(J9_7{E)5L_B+caE?(Qycoq zC!%*A9QFDbfr`p4C;K)!8zGO+TpNdis`oy^8$b%)0J3hmuKl3n|2sX478lVFmXpa! zMsdIT!XKCdSuZ#W;;={))T0q#74K|x2(67$q#AC5tW9<6E$))ljPlr>t>bDRg6v7@ zS>%WGw%0pLlvP^aZvuiNS(h8L5ADV$C$k(4ltylTjJ-`PnY7US<$VSN#YJYX01>L~ zW^AXWJR_6Vj6RDZNQASX5UcrR>nRheVC-1gSo&y}Q$RbA9-xP|%1EJL(&>>5;RG^z zJuXC?Rz4uz)zG|D(A@BpnDUyO*n^>$$1c8=7f45!W9_ND;xDMj24h38?=mKrBgY(s zh{(|~_ZceEMsQ%)hS((N90_vGJ9K$)4_?YvzbyQ1@DHUzYm>)AE@^w-aa@Q{*W+rJ zKz^6KeJLqs-TFv2yWiH?*YzXoolK4cfZg3{-1AN-YE0$@@1~FBh954EWoiJ9c@{J7 zu(N~dviW#EZAdVWtLuKr>Lj7UJSw7dV-|!FKJ(ZP?YSWh{~uvKdNabTQ^7sVrUe&F z1#nKm4$KP{w?}zcO_jcvYwO@E()VK*-hkVI&^1FU2B4Qs|Sg%ob zWKKVu&@rVm{Ohc3BUUoyQs?)0Y*6BZ2z@ow{YyzmI?3lx8q(LXrt%CLbVX;>(qXjYifJ#KX%yZ~ zYD)m{8faJjtBH>=K#UU&0PM$QK4$T5PEvS_65r1axnNG%DW62~uq)jlpC>`!RmE44Nonl^ z(6x{&L3Hp0Yrl3+c48K&S}B(#qhkaHIv8jqw0QSR2xv@C=sL?b6&Mr^ltB90*a!NC1jF=7sz%4W<_}}m z!-(m;l_(G>MXEf1QokNL45oB&I(TZnBI`W9`$!r|b&av9Eab3MrGgx3Vh>J{_e34C zIR=?s3f^4@Iv({kX0^g*`j$J@j1f~CJm^JQXdyDTTHhIN@|YrouN%n0jYTx>Cv;0M{n%*g89QF51 zoZWb71KUvvlmabfrgiQsvN0J@g2wtz-BSq1yq+Y53}uxC+dPTe#t=fs zh2mXWfoDAq&Gyeor}8IL8hwQ~=dS67n_JA;9Eg{0f#w6BX3BosNp_hpK51XE^wili z4DK{XY>zBMdypYW&Yce4r#ZR4=qWvri)HqkC~omW=z-?hDr)2I_8>(BOOXV}_bHhV zywqc%6MR{qQ|8m+Q5(R^OxIAohV58iGhx%nau;G!G3q@e`ZY9?OL-a)Dqrz!0+3VC za)T6j3EJ=@TeK0-F`EtTfE6Jxs3_eNuL-1Im?idOSNi(d-G1ZcjG40O%q3b=X_4?X z==d;%mX5}nHQ(hs%w3Md&-mSWit#PR_%>>L)|L}Ha>D9j=dvd^-t=m`>8-9dv>wey zkABr#kLwXWopbn2@6|U-4b`vkx-=hn`;CY8u!auf&ntm>&z=U&?7@kOH6XP#fDZrL ze4l`{e+YLZrOg4Fv2o4qU}>2PQXZIx#;M?@XQj`=u7@XwqKL1{;g19Ub6LFbCk%{` zd3@m(^Gf~Ywo!<~PtgzA`$|Teu(1=^D~S^`nPt>-YcEeqa%U)$*O_PaD0hv$ zzu*qALx5)#2+*|@y7~%xZ>a=)#p_nB`|*qua2{t0g;;m?1017@r|RyXdDH9grnjrw zaJ!nl-}1JW8}I2HyHmU>&pZ(?nw+k=ynWy}p%Y1NS)$Xj*tQjv%699#Z->|ofRk=y2f_Sm zqtM!Cf+thLwn7Y2==NHjwn=qpcq0oHl}D#FBTthek0R{CVXMSu8TIv;58GbBAA%b> zqL&5u|n#d?p?LEoeN3(fvn1brjq*@-;{^#{`E$B67%o~#q?Y0J~Z zv$)_JEF*)vbb<{<{l{8G-DVm=Y1+)3fEWhuo1%xYM^ujZQ!C~gT5A*F%4@6@me8yB zO@*OnrECVuC^#AS0i01`(nZe3N{$iTd^tmbWqa+ODmL{ogwM z2I3IlXE2SL%sHZ_VDcNFX0I$DvI5HiPUhaZCE{8mx z)AfKY&1~Ou;J^_%}w)_L05c9$zD}!&fHl&+wQ-Qrz-y_JS3kj~P?AL%< zX6LIvHz1R2K=HP)1mDlt=)knpnL|NeNsE_-qo!fe^y=SGkkEq<=mh1nSAgck!thhk48q1Cc1SEqG0QmK+i^ z3POTlnZ+8n8XD_CjO9WNBcq`2X|F4-=61Lk|F%NyYCf- zsu`8zA4|LwXDD4;9CMe|kWqhF{hGnVP}CPu-UDqjQ5+p4iy8zK__T;;z}^fRGPFM} zNA)E%odS8yv+VJv4HOBNQu2e!=N)jCA-;PQ$EScoQKE`Ig)AS7fyhH8*?euJx!#`8 z18O-h?Tj9ON%A5*FSGc#R@zq#|1#|WGb4}(*K4O!jXz+}NF^Zt{*E>dDS`)NG!L9W z1@}O2fWgXCH|K_rYQ*!56s#{%(7nWYGa-_1sy?@7B{LsmAcu=f&R+nbi%V$^VHY;G z;PH@XTO}==-Qs|Eu2WvZ-}i^p+%ppuL6?B;a>b|N2AUjMeSV2_Et>2l=EtR?|9=R3 z3#cf&wSO28q@_brQ5jUaK^Ra(9;8D$L`pzfkPf9&MS7%Eh90_0y1N9VySs+?_WeBP zec$8xpS8ZVX0aHT=y2cr-ut?Kbx~jq-=XhkCbSKn)Jb>!kjpY(M+%N<5(3d$iVCtP zVYm87B~!SeD36x;HiQ4xMl_MGKq}q#1|0>>MJ01J9}7T9FJ(1lRFF%`FW>n&hWCbj zh9WM6nsv*mUw+B=o&Nzla5;9Angn<6fo}Ngf=c!U_F67jbLuhZ%Q*HvN8J`ik z@C4|`elEUNc#OCw7o#<19sz=fo2ew%k_5t+f`MRE4k!bKIpX4 zYb82nJ76>M1-K3lO{(4IxA#BKYMKvS3&2kczO47@>pje~{x{G6-&&MUK9}`%nhVax z3)EgE4V2dTP26Y8O_-l1cG@<3+BPTt*@YHrFs@{~(19Nr{|0CO;|m^zaz^j4dh|;2 z)j2X-SGAK~$Yw*&8z?^n57q9mf#>SGrX-BLN-;@(o5;cV0666rTb_2Tuv1F1e~Eb` zDVtgcnw|!qES+@8I;BB$<`bua1gys(;Uuim1z^)q#!DYTM^;{32gY1eFvYi?d(rRK zctrQw?&NBs3`&7gL<=PUs)>jhN0VoO6N6NP!|9#Dza|1Bg8v&MRgUcp zD3J*OX6ygX0bxEp@c6Zq`UP;h#bkCDFk*M)_^kUa6=Hpma%NtAvZv&12zFi@U`Z{_ zSI&7NREzmkv!oE8f;H~_N0v*VsLl8{BRFRNH3=C^5#feyPtCntW6eTX+ zR?WO8e3G62!wPNcH2H*})(((-fTNCM4+3BC84EKq*xrf| zl1bhw^?^G=IDPstmFD2`(x*Ez37m6-pk_qX6+!sj!6|&>qQ6A{Kh95lyp>3t<)=0^ zcgia9bCM%oBs{(DdCNhKb}yik_WN z%FGJYNSvq?BgQowth7H}Dn^^-#Y94D${>a#XVDqfPn)fcQ^u49Ibn z0PlWdMUp}C1vWE==@iEFTH6vPz%vHQui3#lo3=4N7M$ zskK#t=?o=sE1~xBOwbbY0)nXmqHBep-&H=5uJyg`$%=2anxT>YTmbYeMK8cf^k$G< z?i&Vbn}5!Y2rx5mL0ZU**GihgS`W)W-$%#)n$!^i%9Rcvv8Hi!-NhQ+{SKrF22?lo z9g~FZU+uD!=12m29evLqB22Nc_Qxjp(~X`ExbKo~@Qb+SAPS>`~~ zczG_e0m&$|0jsM;6Z2=69Zax6uD!e3wz=4*;O)nCT&KGr*HdQA!nZ+Fk@@P|1EB%1 z7=5crGva89Og{fNE T{>R?iwZJeq4!M7z%m9SD0?1_Bp}OUAYeR!$QM{gwCNlqP;PslD(w19{$UkJmN#{NX~{yp3C z>-ZsQcx)05F}Ke9WAEPj?5O(Q=hkOF_N^ZGq(}Q*h|HQy^Gf$h^Q1Y)^en}!%ITyV zjZ=e%&qP`oP{16(alW5?MMTI^tej30Vtct0C}A>gw6bYoD(?l!z^>g8_8GJ|v!8GA z2Y}Xc(w88sn|#-g$xX_5{bPUi59&a?nH?b9m8{q@<9SV=*Wb52{acepj>~2u>;N-G(SBqu*^9J5_yetoIh$F`HXl{ToaP687hQE{y2fa`;0nAc?iL zbvzF9pn{No{kpC}DvO7Po_+J=p`}V6qADefMfncaiJ0=xQi4^Uk6NSzNPA(?{3R`k zEsDqB*v0cB*U$7g5a<3$XQP&A7M_Fp@?;0p9WbsJbp@#|tSAioaDkD(lFD2z(*?wkyG#kA%6RJO;=l!Yk=tt&CF_gRY2wZ4B?bU}P-)`-hOQ5x-?LnJkhCzX#Jswh1x(6Fq*RiAJ zasm2A8tUiGJQvmxSWoTPMg8{x7eE3j&%LY81In2wkC&)OIZMZ0wFj@Vm3Lc8<8+%E zDnYp)J1gHJ1E@5#Zd+xVpj>f+vU3Wb(*m~0OsuvV8~GwLSkn8}4zNsosSJ~**1jtS zikcrDi+8cEeZ`oPIRa?0Dy`k;BfuOct-6C|%O7AH!W$r+DIU)M2JE}Dz|lZ-;y@xm zMvD2zYXt{$sAj|Xy{)izlSz-mvkcn6m7&L?lt5Z2hgO)!-W~&jA)y87fXQp>qn@F zz0NOi3zxg}xIsDt&!~aAkJjcOiG7i2-(z!N_?6P{}?VY^Z*;>1Di0$6#9x*qRZW^P_x=#)V_k9PXV495^OBvYT_7IvR zJ-%sLR$aqFAUz1R_Rd(^Pg8>NW-c%abZmj3V2N@2o=9HdWMMwY*h`UDd$y+j2p(BhJR{dONS{;}ji3&RO7`0M!2BJ?#{rK@si%Y8 zOD{>oYO%C#x?boAb`dwct&Z^_K9k ziqoH;W0es6)v>V5DwOcg{gEP7#(X6uMl|jRJstn$C0T;k1t=n;Zh>7}($l{a9Ho&T z7tB{GsE~bIMmH3tKF5;wsv>!yg|Zz#u6qHgnE+bsUx=+wqDZZ^w{ zuaEl=N5>{&S{gpZ%wK8yH|E$1S*Ud)+iTIg4k ziC2`b#y)S6{{A>CVZs}0hxhFg3PaExO;N%pDyEr-`{y=Gr)cAim09$nK^YurWo6Zf z8}}@`fopRfZ zc&XZv&P|Pew?{tY8(Gtez9MHHJBN!p8cQsNd%7e?IH?(z$DH1M$YX2X6H$_)v=hWZ}+GVmg&41 zNR%TVOhURd9KCT~h_}@HZ1scrpso8nu91E;z^Z9pQJ3MG=3vQ{*Gn9|zt9XkzmK98 zR@w+*3-2E}rY(zJ7Vt6dy(_(uY;uqJ8t&QcrH56vtHxEb%bWL&Iv$ywzJ9&lcQxLv z*_$~1rRNRZf2{TIF^89Zhf;P*Cl+&IOFJf7DXn+ zyG+F}TG&@8!t=uL8bfTy=!Dl>U;GzDlpvivQ#VA-Q=0$9% z=SD@S>1jf?)Us(0k&$BsaKQszBrRqK-#_nJT00h4UW=HmR?#>`OoWBVX2u z_?e0FfJCB&fG1u0@~m-swhkff=Km_EU{E;)8!O4c<7e<40m(p|Z>EVV%G+5C0VbhU zCGxU(liaW>6y=iKjoln1r679M`&Pz?CN#ZkXllH2Enhj+zltb*H`P{?r0j^EeB9ev zM|D1GFIO@4{BhzYOGi9&Uv~%#f0g4Mi%jj$7bUut+gVj*-IZOk@@1x?FnyKHE&YAP z*e||R`%bTaA%&f?#yt^8jscB1o;CT8;(wJ+oZh*E^nUueO1X>c7cmx}arx`f5>>v< zMyue@Vq0{Nz9Jcmw?{o5jVLY^=oXeaoSrDdaKd76Rn8fo53p8DV(IniyH997TT*jp zsXHlNOp*_sygwh6u$V*X}ds2 zWdx8|%w}rKVc5&7NF=w#)T+M!PlXkq_o>x_E;}>d$8N#9xlymlPHjLJTU{J4m=gu| zP9T7~j`rTbL<*g0sB^1#4|c?Wqdzag+(lnf%Hu) zcRqN1UgYGQ2ynng>RaGhOKLZ#_NeF!h9%Jg$Dae$s=36M!Xck}2$Z7MdgZMcWO;<+D)ndop!g8#{Zu7gDu3rK8+9r_B5?Y(omu{$J$VaTlrVU$wvZZJ+1J=ugTGi~1b;Q!c!&*M zn(vWVpR29cS^F#LCgj)@`n4m{Tg(315x9nvV%Gr>Yk!s94QK5|o9)1IkyNYIOCNxMh2IKy+r$57dMVl$Ng|tI?Rw|z5 z?C}VBTjV-i6nW$CHS?4-} zBg{J2A1nM(y8VSAWXDx9q>3;*g1qDDxds6smMGosKElk8f^ireT9#5N^1HtW+I5_uDVn1}IG`tl%eZ$FeeTMcB6;)YK@ zQBkm|y%g(9nx>N4FvSR228BV=B)dhkr!)#C&hh%$W0U?&C5z@Et6YEY8*MfZB)iP+ z$T?D+3?^TyyI?{F*f@*yc!G1Y`qi-4Ah*z67&J`R;GsES<2{@T8KFJ>1)J)9xz0dSnFf{1{Mxk?QX1< zu#sEe`OkYv&LaboTGpH%RBkPDI^#*r8fctgm$jHc@O|zE4 zoRuA2=WERl>8QSuNo(xNi50qUNnhQ^v60LX}gVANc8kO5~KF#$C3mCISn2i z?Td(*5qTJUkFSKnk7g&)s;$Cf)xVd6J#R*hw2I6$POj{pBPL87-Fq&pf0%YeN?ENd zIt$*f{e2gm9oL=lXYYH>A48Ff%@PWhaRCoz=%l5MH^2iLfq+q`TruA2fjkQ?DC^a+ z*v=vAtE#nnkNEMV5#TZqN1aS48EFpAya#)+HDbOXrIlCY>qD+rKcY1VfJ&{?F|&f$ zz3tKCoN;IY6Q%`O zWoBKF8MiywD!%6)5g@l89)OItSOU)lWqmBRv!+#jS7O5?p7orfrHY}+Qn1WoMIRR} z(P#E!NjKNJYz7$`TfOiRHtqPLd~imMs%c{hT}Z+R92|yfQM!IL9kLZ&EH=vrmM0#C zpp>Pq82)|vdwcv{@H6Xp(-_&1Vz0w&L;s4UgolNe4*(nFb6n!sBhq?#zv84cA9C@X z&(YMa9L@>j>;iQ%Tl6yM!fo3cW>phoqyF5qK#@Me&Ulz3GG2yN*G8MK>JDg2hTt8Y4CXv zscEpO8=sa_`-=PG;Nt30tg^5JfEgCyeKVvqlZ)5Ye+5yvdY^V0vLb_id==ALT>Si2 z8b^wdJzp~J-Bcc$3;<9y+~8d7%7xDh=WQ0JwKq% zsP04R+%UCqMvdr%Cm}PqiE;!d?Hb`VVQPvZ_s$Pd-o)V3YbS4g(+H&zp9ZO+Q@K7j zk<5`y;-Zd^fJ$4hJ~rW?n5rfzWyKpbJ1g>r2WOF7nx|k9Jh~7d^}T$Gk^HGcY+89k zvzO~R+bc1f$?|-F8kkjDLr4ig&q<)FEj^NmrH>wq?YbUNd=je7*nR@hMQkaOwPl+? z8-?G>;3%KT&sHxEf(+$OJ7oXZ>7S-dlhuo_%f#!w0ZlzQt7SBJ+mwMLT)#j-;X(L$ z?fpLs%Nl>hKfTl=S|6J(yxy(~)7U?zZ|_djw_F#C+u4z8>}G}CX?P*~F59v{)dD{^ zt8N%iyF_U5A8i&LC7bT2H?v4p2fA!|#@*>gdJ5^EzG7}tT?u8X9E){cxorU-`qA97 z&W*coc4$#ieZ$p#pd)8{^k>}M=stXs&}kQ8Z{hmdm}q&UCh62dZrzxfS;_m%C6Rfs z_c$!|#^pyXl1Gj|sV6KxFejAhUKmYqKO(j#aaT+>EzMP^+p7zuf@QEi51f z)`>G`@|ZRBssdi8ea5xd996NKr4xA(RRgLl-1&H4?$<*66dIdA(J)8mVdG0s?-K*m z%uu{^&^p|WXQlKtvpi&JZFqs+Gf4)7fdA=ovV?1L&0fdDMu=yHdV>b>QI z0h-fh9G~5BX&i|HZ!xYAWk;NH5TtJJHs+Hjk)#YAnoymA7 zE#zF>0e`w&sR)3_f_ak`fZCg$&5egBHg^*Ba4%5t@nA=qiqkPgg0UGR#+m6_*P64F zJ&&}v40f4{CLKB#&xSf&Ky3M2x$wbqJGxi#y(7P|=si^5W<4-evbzbARqeNiCqcDW zal~>*@gckDX^iwR+q0%vjD?ayeSgaTu4`;W8K16& zWR2)Kb~kJYWA1*EVY&}pF!d)df!DJ=*8hu0I9>B82mmy@w<>o!XB$`u+fM??W=3Gb z0DH8i;(q`u3JNd9CoZH}VR3KF-m8%gXUV)&lq-p9?^RF~)b9J|?qw-r;K)Fr{WTw- zi))J=&euBaoik4~pHn}X?YN+IEaum*x;OnSxtK{LUTB>&kYC(5ZV+wq>s%)&)R;Tc zI6m3)(^oJ1Ug0Az>$8CX*EfM2BRn_6Hz-lZ#>vTN{Q6U(L2{v~oLn(*y(;(q9aLno$0}9txgn8?|ey1+?+crZtL}K0HOUKn3qyVQBSN< zu3ecin>a?F8~uwy(QyY^$UiRdT7spo3tQPmbEzp16PC;rwgf!0REcFZ3mzlBLvA0$!7Y9m;j1kFZ9H8 z0GM!KP3JB^2@b#V3TA1ihq=Y7ai%dFXev9$E3iCA*Ey)ZzHfVf+pz6R=?bV$w6s3P zP{$%4KCqM8kmu|E5}RfzhQ zhA;`~fwId8+D2NY!a~wm7J%(qOytxa;H>SzAGJq=M%QJSJ^4HJbPNyN3V&Xw$EJ)- zA@v4%H}S6aZsXuzh_#4)jD2>^7d#Z)dS0iC{;|AoSNiP0uj#G+wcPi99n~qO+DrA zefiI7>l>L?gW%hc_M5EkabbKa*^HPw@S%k=M$aP&#E=b12Rk5wBJndY3YaCzEBVP* zSh(Muj~ZTWKh4h8>3N>5T)#k=!yOTtQ2o_nMT&mORA+;xci*8tcD=v1wN>y;dE1l6 z41RPVv+bFAEV}f)yR|RU^_M#{bD$oawY1s(z@Mpra-}BRu}*Wmg4OfmL!06PbEc&f zZ!u(&_`ER4HjXv>7U8MGGBGX1G%qF|KXn z)T_j4Tk{6Cp4NJEc+}=blN#fX8+3njS^vXbT?@dlIpM0zWI{4W-_Oq!;@`j*@}epM z%zJH{G(dHHnaIn*un)pJD#}G5|BdqOMdz&hG}=H_>qV}Iv@3AzjQ5Q!U1>HKla65&1O@-8g=all!)aY8}bUB7F9tIr9Q(w?XmCAe09=VK^DcTKoH7Ns+Fgt z6b=?5n`l#}J^Oj54l<(;_KUww77(Fa?h@SCU64<8k=k(43O-6CS>#D$pZ(U+yOpPIGnM zViT1nlTKmF<8%}6Ukx;U-HDVSt-o(!s;I6DCQUSglbm^{nfTD=B<%*}bLxva{0;Ei zY&{TLt(ZcOx^*jIB!?aYuaYXw`&Yk#;v(mZtrCJ0gkm=gdc{L-mf=_%vH-V#D`i2i zCxN|G%rz?7#JS86bw@sCQzCX`N0Yb7Ifw%0ZL()Y;V}r?mM%^Ghr>en5Rkg}D1Kt; zdx~8vINs1_^3tsTT~*GT>I5X3U&MJVmHbP;% zE1I^rh_t=Yy2}!~Um#g&DalJUPVxrWvg5IpX;qg3mcW#$+w8@CPvTv(OzSOVKxT-0 z$v||+J`}x?_6qOr_}f8+Mvx|=^1$2{kq#31>;`0Ehpf_r%~JsEodtK2Yf?zD^Rdy( z+zAk+-iQK`t=x@7d<&dFETI(b#@wm!+32Z?J{bk&G9{HMHPsC>c8TyasI~@l+AhD_ z1Lbug{^#k{k|nE z$>o*S+kWZLNjK{$@N3J+rAeP`*uKDv}}U|ivaML#`@tx zxX=`vnD0%L^_ZGLcJsRIlv`o2q#>CL_-j#7+MPLdvjOx0pn-(Vl;jx@!=%2Q4~!M5S~VL!_j?_q#9ccbNaU zF{Hk5828uBmo!m^bQwpb^lK6Q|+HBg$$S06^_M@w(Mk8w^TJts68flqh z(;MlgU`efl2Czf8d5$s2UN#5&Sk5v+{FhM=Z5_>uusMJiC zbW@tTLzOIjD14Bt2}|u-RTu8Y_WC9>y7kC=3G`K<*P_8jn~hcST@9nWURWwnzM5;< z(h)#teP}2HRoAd(a6orxVnFCPNG)yv=2$vAH|<&FT)3Z=85&NF=|`kN4z z&R#zAK3-Q6a~MkJvp_ZZy!TDJm^l~#0XG7Cn^2M8kk-*P!_WxyBMHXVLaCAtvHEt~ zGS+lnF#CiXBPxk8lJUy~1YWyvwN4geF*rc?Y>bTP^JZx3PNeJa&t3eWqFPTKBY~-y zm4KNJg;}te)EzODHQ>51VcBLSQ0R7k2Jb@+H?r5y0971xe8@{|sOr}%$JZ0&&6{nl z3!uDfiZc4Ig6}=&@SAH(z0sjcLsSEp`?dvZl6I3C>_}^Yb0rjFWNBk5X^Z2#m!dDZ--H&rQv0Ek{IDb?T z?p$~oHSQ&s%8>%Vrao`{p{pAKmz}DbtXSdCQig5n5w4bT4jI;OY@!|uOwRDXQ0dnx zpQuNl@aVfc$UM&IQe+&8-rbhMCDj|UirOO8kXoS^ zSL8#ol4!b9h!8x$~wc0H9 z;{9i2vX|(W`+xOnsalf#bB3VwZ>2JZ>g3%2j@dF0y{D^aq8)j__>y2eN<2CS@{Au+jR-^|17*V+VL2 z{FHIm;ushxT|ckleptm$Ep*DZ;z34f4I-9>dsZoqK`myl$h`DD$+PzYWHb7}nE};l6@KXTb*vTb zfSL+)K7wnC9l^WE&kN#1>)swkyNb8CO!Z1EuG^)&8~Af|o!tRq2jw9< z$8B(RbQVU;ohOC$-eM65mVs(45LDCIy+@h;(kT)YE+9qt`81Ij5JoJBRDqI2eepXN zn=EaWA?UDir^ZX zWYVwG0G`DTNG}7ytcYAowjKBfEXK)v!y&u95s-O#!BS>0q4IJt@z{<#%j{Y*Z||oZ zflz{!!nuwWm12z{kbFZ8f&xOas8Mr%W{7isFR|Lcv^gUGrf_zeX{Cw%%}@5iK&GQm z8AdK!WEt3#_S)x*K~Li9v_^}(!{{`maC9kSy5mUFb;4vpskRovAV zZP8(53<{P>`20Q=u_T7!kPiiRryyk;L>tQPItdW&fI95=g5wwnVf><7zHxjCC2Qb> zo)GrdG5U}J}g$!g>lgwCoW1o+IL_=FRzLdF756X(=4_XqV?>(79yOc zB&@sQ###<549AQ129v#9JvAbVX_pq^<%T_julC+{wKCPZR;N0k|9(~s<0H90P{ z5Byv*<}shY6p3_iGCd9BZ(K5Ky&u#YM>#G2TJg_cs!tw!2?QkOgD@Uyn^O?^!*Q@` zg}Y%70F9pC*A*q97O?>|n2GDl;gAk*7XQ;hpsh+HlUagAI!l@2Bb!2X3SYC^c0jWj z2*lf^U>sBV{XI}u5l^4A2sHo{>?>u+7x8d^F|#3lMX=nFY&pd z0rZvS>L(!g{WB%eoJr{P)#dRl@RYMt$-oc{A6}K&f(PFSWR88WoJE6CrZlzGX>A!B z;iZ@FA#Cb9?GcF8-B}yr@gnIJo)7aGd;%Gwz6VhWoobBRSrQO13yr<}FpvtxT%!nF zJ2?Gb)q(xv(UFFR;E0L&g(UR1xNq=uAQM!*#1 zYGVXk!0qkPNQA@lz3SY962kV~Nqwu_k_NC{(~bZhee$p{jIT%7cxfNceJv7JHQRCp z>`Apl-B-fFB(*?c;J63>pr+)$r#1WbH)x^Q-vE$Eo1~fl%nX-eV(@jZy!cF$x9oLa@?a_(U-E)_q?dYL{|+rdBz58{SjoKaAG`IS?RmGToF;vA_>2PQ)yW zgR%_KxKs#N8XCc7NAJ|?NB6p0*JoNQn9+L0QnArA@G$dUhFu1?WQzT<*5wc4sOE~9 z&J$rygI08l>p5b+_{4>{XP&mlNQQ|YWi@J2lp?+re}~JQK`ofexZuY8E5>7t8s@=B z@wuAck*keU6XkiMKe^-pUn}u;64(8t=#=)XVaczoo~D{5HfWQ;=B=K~f%7)I?za4%>veJE#{gCnD+EDA4pJO&2seC#VS%sTRw@EDtaXVp?y2yJz@d`hs46e zghF+5*iw|Cvu_h1pL_y+l}dSF;&YYrf^o}ie;`7=8*je2Md%ry=nxBpw+*8W$(IL6 zSsrtxJWOeSwtN|Tv-nbQq_g^)2+I;mKL@{!Efi#UP4P(HO1Oj^)e8YbW0u*uPkz9# zW+}NTr$(|BpyP*zvDr&0z$AC z>#Ke|0oh;*`<08M31COR<~6!A*-XO{24cq&A2LCM_u_dVnTaaf=heQZ07B04l>G|L zC0>yFSMWE`lYFsZJ_{;y#SpDsIyBG-BLlwWQ}k{_62IQxPu6nF26O zw9rmIkilYCN6yGmb_jaJ_kexzE1rWG5W36)nJOWW@I6poWG+C}hn}}pvc@Kyd$Vr; zOW^ka6gBCuKB;0SBw*BzyO5z}qH64^j!5X(pYBO3Ut(DxIg`}3(ZZT4@*CTZMqMsP z#jTEO{e0#Lk)x+P#$#v?uMOk#F|-}R{8%vJmnNN(_kn!`yMB&RXs9Q5Wa4sk^qq*{ zWeaQ^0liliN$s z`sM~vKQU8#A8$*2RwKHQ4G{iG^d2Gn70=%`+K!{fg^IB#cHBOalEi zEr07sQXXMe--fV)11_M04-QwL7JU4e0-N}qoIQfgZMSjSH9C55eZHiI!-`%S?nTn& z8z7QcS$+DPVL>4L#hQL`@6W5aW)Sa7MX&3KX4rqShnNMV7~`FIn~w~unXKj zu%HYqbkAK14hVPH91q0fC&xsjI{*>-k)>R@1-bt%Haw|*#UIUqZ08lAHgYQooWhn&0KwS=cVr@R%Gs{NoQA4W?GC`{opW3`m~#w98?nO1(%4Fo z#C7ig@~)(a+tBXhGQDBFdt1>w^EkYoEzJW+_Z~L*U}fo0t<3nTA*i)1vWLCS;mTq` zYB9=9Iu?!HGs47|oz9E+Et3;mx{dFg zb*MSnKX}+8{G(8)IS^>*tB($r14geShIB2mUtqE*%C)Tij4pMfYRRit*(GQ zfR`fdH1J(^7U&t|_>L=XG&fJ5FA1R!_&k%P`mS3(!|=})eo-qArnNMF=3>1+uPeul z5Ov$CIO&<315Vz3e~@gPH>Se$@1+6wzJ!&#g?g+Tn|VozM`btl@e0z~tcr8e+#|L9 zk~KrB@8Vx;B^2PCRa&p_VqE}|a)rhQpJRHZGLCxEwEhusAsnx7ib})lOEjNcWLLhu zp_g?;{~+$=knsln0p$uA1!{f257+Zv_&U0TG}5&?sdxP%zH#DWs+!B=wG=aI)^l)= z1wO*Z{HC_qt5~Hs>8!Z64c5)HK4&Gb3ma(`dF4JL(CXFI$aIvnfol)PU8-#cDc-Kh z#p05&>Bs9$#wSW8SI))sLDU7i`D2eA|G>;%Yk)WtiKo7je82P0fW<2ipdKYVx1pN( zGX;3?7NG{f7z>1dd8zsZsmZPVt_3cTov+&-FtIbX+Ly)KbOg0KIv}U*vP?b{=J!$kfO6{BHO8Qs zDFIQ6qM2zIFFJ4fM?%_hRU!vKRck)9uIu_R-3wLbGR=0w*Y`jrBJNtVz1QC=+!vb-{`@!geHXCA8HU(oD~8xDrKP1dL)7C- zMV(J>M+KwIpL3Qwz?Yvwtto2yexIaAO7$ffOidi6u$q)+TsdYh{khc4zg*&8V1%oq@Fl zUn@MeM~~bj%;2cTu~xIwCLW7@mt=c`3X)NGXf3lMMb%7MgVt;A$Y6ii%sI`*chy8#THPF~lvo(Dxs9AYjYYUnKO zVo9hDA7n;6He?s?PGEa73D!X)@V5(w=Q0t9T`yO00Z!gbbbH|C*#i#RN?^SAbQx}q zP72Uiy>zdd1qy;k2oS9-E{b1(wEFgv9=W^o?l7mQhrmTat&?>JXf5v{yXORuFr)-%f`8eN)YAj_q=uo183wA9$Y$UtG-#SD1u{{k7C6FC)^^V z@Ws1G@gqE-l(MN*`&#UxUjv4KHh_;w1Iog9(cCQ4>*Sc{SNRa0ox){#!V(Y0)TOuO z<~dXLLtz*{rjG5h_937Gg7qu6R6_qi59=`iWUYUN9mO1{XHyszyrw|Zfbquam%vqW zdpm1(Lf6YQfE#kxhy3aGz9Pc`!TH_oIXgvC> ziH-B5uA15}Ql^gKJr&N>nJTyvEh;p}(#|J?fu0@;Q@HavRw46okvuE?DS_N1Z{NTJ zD$651%WV*`UfSz@Tl=k|5=xyPw6;I+EZc972DcX2@|KMTDs9Xg0feUkjlk*#VOBI)`|^DcrU>+brf$&G-e=973~_1gdd-udmUiGDgXqUvsp z!V1nndPx9aB7<&16XlIydQ~9WUU^=o{lc3P2O8jvDO4=@i_i7sM}AX71Yu_Di^Byc1QuYf@)OqN5wwfv6fF1Y?ciJK^{t(aUf#z z_Kd6Uy2cUZQ!(!?&`;~w!UV3sc$9?m9z6hPq*B8|((Ex(g3@?*|6-z+QIyj(1SweB*|1zN;Fad~n~P z$^EC~8z8;3gcRdX#Ff&Sf)I%p3DaQbT?l1|=7cZs-GiHtR*HjY()QKR6ZXi42Lfan zRKAdA;j}P@_tdt0Ho&LXH+|yxw z?y#<>>cFeIG*<-fCFR2R6EUx_6Bp~TOk=V0KH7Envr>1Hs{>aavxuCa;*wNj_W26< za+?fu*u2<5AaQr=X~n4H@4NNQeSw}MI`E|0uEkLkG0!oyOUut^RlT#DyJP6an3!0e zV#l7tIfi27p?0m-Xc@c2_6PfMrfSYjx0KM)(EYQ?f^c`KUa<{}P-EfcDV;amKc~(| z`LA8RR{wLorQe0u{D~P4U@TsM z0i1<~i+@i12?fneB7m>LQ463P*9g`j5#SC`#u1fM9CyHvT$nD8%IXxRXl{tEqpTi{MK}4WbVl$a-@HZ1&1?57ho2tz{Xz5 zyR}F5XN|_D^pe$4zjuLzH8wE^IzwHtsfFgHS!v>O%y_>+h+aJgOMQbs=G*p4b+VOM z?48u@zoIsEpM?GhQU(e(0$X)kc;}Zv_Ru$&uS+P=M zriqVHqQEzm7N|RM?P%U5YGMW=6aNM>yPv)U4Om^Ljj&c#{83I#2oOvMNXFCW*?n{3 zE8`sviV6#{No!KD%*{PF#5sfwghfi-JUXn*128o1rgbMke@!!D;J%p;IOPDyxcf5c*h30)0IK5<4k3eVb}Twm!6q&nyN^DO zo+V(0G>qz4<@)^uq6P-Y;W9}twVQAVvPl9VRtz#j$nF~Tx4)L|l|Ap7eTPcQeXTSm zC(cZNzLi_hLMQ9H;n2wud(L@-QIwyi9sCTPo$3sbK7FRNPiaf%3SDpzS`RZF)7&p< z5)@L7 zv%+Mr!U?Ft{B^PGpoZg=A(G3hJ~P8bVq;xnY5PY~b@I`n)8X3--d_Ktr92tE_}&-N zDDg>iKJn#F1B$#{qC33GT%hy1!XLBxU3p~s=-uJf!(l3LcOm>qQUQ;sEN5Qfi0{|L z-79awgbxXL-%0DsIFqdQtfH>sJdB{Cv9A|@VY*_Vm&+g)hIQeGQR_R$?asSxb-}>+ zLgvuL2F;Se@+JC&dhvlVs>N-%O~ni~g=>}89BCNCRO^-O1&R^Z(xj79+VE_dl&*R8 z7MQh6m{jCEWe<98J1zDWptPEYmEGYB?D3~6yT>_o6;uoUluDQ7N{ zWNjd;nL*j)7~qgEawUsCVMgBMW9d?Va-lV$;ZoO z>b0ikD?!Cw6_HP$%Quh2Jsuwi_@v{_WZ`s;eHX4>#Jbt4QEyRnFj7e+(y4E4_t99b z>SZtQ=-1@bcO%b7xH|JbBPKm{wpzPg3frUKs)AMX&&D7ri4)Yl!)6ir&-?$Mugdh` zCgu=pn*MVSSIV%j|B6;HFU)M;Weo+I8PF@AcavY%FFd9|L_ofoJ`()mqeiQNd%^XR zEIxxILPFYN%W2v!U*-q2%j1?loCwQ^DTVG=mec0aiFjV76-@tsq`i4ul8LuJUX3%= zv{*Wm*EZE`Z>c z7^t|UC@84?9_MrK{oT2*na?uc|4g6i<>4&v^M0Rm9ui2{)P!#rJCx_T?^*gV8L{k5 zVZIAsWqHF|tTjT*hDM#HH1P+@Z%-bS#0qDl?Hq8}#w+oJ4puB-$jl=p z-7NV?-w)cTD}OB6f6V{?|N6yWd>_z3ZZby>CmK2U8NIvu^8twAIv=B5C2y`%I)~i= zj5FWo1boONo-2EuOwF_I?G3vRoBncf{IRp)4)382ai%vw_oqW*rgtUQR!449G9jVt z$M2hAM)t>iQ4d2znx$1$p}FC}hZifp;;1nZw%Hv4;gq@h^3|=_O(`M6*_%*?+#ZH` zD}U*DBz-#vC?8f6AMv2pPhK{}s#8O5Gd7nrf5BQ2e>5ipG4}%_mAQb=i z@a~^|ofdt!&qH(Dk%h`;q66#jIpv6uEu7A6xYpqmfa0|o4@Nd*6o#eT4wKszxq5YiKHGkAD{x@~}FTXz8x#egn zdtV~TUelX>?ti; zwUzNRbL`4s+YTn0YMb}7hMBZ|*OJB0JHDH5O8CsJS<78g4YS9)GAqk`yX2wNkq@HJ z>Bda>9^HrNvUq-{V0MmBr{d4&Z<8Os-P<@IdU7x;al@>OF2-GO)!s5s(+b-eiIvUc z#;w)x#Q5p1Pdd~|Qyu3jXuKl5>3;imt{mc%{>O%t>BYd-)LWW;XG2HucK zs+<3j{u9#uY6?bMy>6_O6M+p34;oZ(l~e&qMCEA1+B3#K9ch|0ujv(^pL@yEBGYCvI>M=mXJpYIjLK zMqr!hPHo{n3FvQS_aN|7HrH4$bH8Y!FoI&C~2 zA^#dOU%DX5j-OgG)&0S|Sef+T@{g!b4D;g8gb_v-q!%mExA^3Bi`cJbUW(Bb*f?1JFf)3HD~q z!x(4aY|`R;**P=A%Nf5wmJi#iHM%^a)l5-HAUr7Ms`xm*E*NMYZ*xuK#JHst{IRJy z_3}KB09dtQ3+EbCVyXQg$h11O_1$);aC;ba!4eT)TD5RTAYHB~kH>h20P9*v(GFCH-5yEh+h0c+>cu*r-W#2+T zh7MYn_K^+l<Sg3p*||*J99(OK=EjY{=<-23B!|D#}fT?0(SC` zL)ib0_x#T@UK`h#8u=lW0ZaYTyiU3M{&t1RSG(`xGn`Ep3V>btmFNSUZOxjHM(=xT zZQ(yfY+hsi+sDJiSjEGs?K!RDg?KNp%oY6XDKnWYc&{E#IhgNFp}G6gz5dmYx-YRutW(@#7?0zX;-B>(d} z{ik2N)?M8@u<)e0N89w|=9S{~u~6YAaprKMJ0N;4WUt@sbAH7JSJoUj>@~R72Cf|O zsA2qSAcuDgn=)7v{bKh|TNZ|PLyetG(!s{}bo~N+eSxI-!V@tzBE)=b3YINEBvHa& z=jo*x*m{Txy(0M&9vNfi={II7HKVl0Qvf%MT<8dqb> z&_u~zE#TCo!~9MU4~@X1A5&fuSWVI5GAnUOJbzKMJalM#D{9~c&egs!TB;<>fzD!<&C*t+C+BRFHZjJ+xm@QusqS{n`SpW(cRwAk5c&yx#QbQofbM zQ*)ewOa@^|0f&GcXe&aGxc1>MWU}q0utzW3Z01+R{$WS!&tTp^DOzL4uT&HPyV`hr zWBqrO;W~EbL%?xJ>o%4axUhx~Bo;VqGx$Ap4zJYglC>vzTO&5E!KQ_Yl9G~eg`V%W zxVMC+%<8YKiY6xOk8kBOTDMAFAiYRmp}G|#puGaOW8etny@tFAa2ishXizZee=h-2eG#XZ#uXQS-kaMq47%wxql( z0~(UsgP3IO3!6iJg%R zH_Jc2A1~r@6U=uZO@9eMyv(#B@XhADt+vg5e`eOUh5WQ(m=YN`&n$v(K zQ9>b~`$)I3=A<;2H(`12wJ{rGfD#j1!GK9Sd73Q^9c&S<3*u zMNz~aNv>)Fn66IiWX?p;tES-E2W`nm-hs;gIC%LNk@Qz#SE*GJn``s==O4ES4R+jk zaS6HRTg8`P{deA>`|iq#D`tE?xJ~D-rbl2^bHBFfu5&B3{rBy(c+e_c@EWakF$P3k z2Z7Pm(sNNIjwb#(!& zY%~IeKFN6YSxv<(x6Nf_IQo$X^pzCS)kMgM*B?iP7oJRy&y8EoXdI|2q@=+%U}8>Y zfEd_GEyQ3Q^gfRLMPs9`ffW9!eiDV9a1q}HCTMwNQF>sLg zLh%u(^qtvEKx;qH)!D&Yg#Oa84-d>{^xNe!U~2`?2eO}J??hp zsrw(ib ztw6Qwdw4TamPQ$1*ee_8M) z4Wbw{JB&Hv9S`DoH^MH-wPjO~JBb0DUDf%N=auqRO zhC`BGW>J_;mi-Sex0lb*f@Ql*(wHs!bF;SUeTsr>Q})dzb^{$X_4zk?I0SFbC*o@Fd+5eFKx;weyn`euIA+Q8*tNfGttI8^S9HrS9*pbH#oG79K zL{Z?KtkA~cGl_c~Zv2jJiq*RRKr*O9M%<@2NK`k{;(Szf~$OxW=C=%+d;(+nN z(fcbNj$-Ny+*Hh4NA5G%q07xp7{KDwS# zII#Dlr1RK)?MjEt+LK-)NzX42j*q7%eQ)i89pP`6$*8W7FsphKfz-S{3`P&oD}Lve z^70&!)i?xjx3$?Q-v$;A+}PQThY<)FAS^JG zOs=3bCgpmnhX`mKxm@4ZrA@xz=CbS(LMz*q$Fw8BX7<1?Ww5cWS?FzZy*m7lFLR4{ zUw9p5<&@1<=&&lSe%Y=+j9vd#jrjBLpIsl}(6*2caHg#;w$DQgfN`#eLO>;$%I6S{F4Qz=$e7ypOt%^@sFB6X#31*-MxR&*!b91u;Z)6*!7w!E5wWH3VXZh^MT5B zvl$Ju*h+^5CI81QDJ7@Zjb8qVc5fG{al0<5`n~WTSgijLOI*vZ{sf(*5Sb?lX`77& z*~ru^{qY^jq<3bE9}j!lr|Gptnq5JmGzBvdL}Y+v9vFMm5h}SHO}^tkMgbFsAT29a z_8~f0ht~M*0G;?$ zf6!V6q+68Ws3M^^WdI3FL#F9P88s>yUv5$2-&ru>e{cBz(paC?D*44*YShzv&FxL( zGhocEXIpjGuv8m%dHS;rOG>6(Ute72LC-q`dTdvZKEhgwb3x^oU;~%-;gG})_K4qM z-|N*YalY!!`Nu~|>o>}LhPw7+(3devaouqn9;IY`Z(YF%;as(T=?{cAvvB*^Qf-)S zv5+m`Fm9?;ESRyM>yd=q?zpRn;tA;_qtuG3$w`gI=O_v)^y8;S*J>N}!cB$uby zUvbYp#gG6?!pBAdzr!~se(AO6#)&ShB7#`x8CkL!?d{!@Q&cpuegidOeb*m`p8lfU z{=@X||9U1zDTP;oPqjbd!o8NVQe=94>=0fvCW+zBxCP^bkWf}2)nn#va~!aU-|F=O zxI9-x?mDl%Aq7Y(9-S=?A%VZ;K3EwPzP3X5Y}+w<^7+)dLqEN;@8KjNU!ykIZhaE# zvhSy@Q&TY(mPegjv z_Xm%=b*Y;e;<|=G8~;a#`16SV=g{#ZaB(p<*tmyI+x+sJewzMBv?N9M@o$aL^t!YB zO`%s8rY0`0UFz3XO9hJ4sx16qoRbaP=-93y5sM9-0l-jl+uQ4CL3-KD~c}_q(B}$}30UvuU_Gw;T>HFp-!GsQZ zSDeVhjU1pmzXzfV#*BO_3*{D#LiyY4G>gzP&zjA&!$8It|9&4IvD$7`UJ1sptZ4n_ z7pa{mfaT`?5-qSn4SjfPV!rmA&lKmlbDHJVdm(oz_CRk2fJ%LGZR~k}A2AMSdvV|CUr?S=ugq5L<()_hIsbo)Y7qRy7^s6Ye1W z4?Y%Ct#%}TepXraVy^mj@jJ7j!xG$5^*(>t_8dwKg+M%PLnOB2p53X zwWy*A{iO+vqGRlDsxN(;QUBvK)PDp!BEsyi?0QR{uqPY3RDzJ6dyuM=*O$uI|Jb@r zY);pX{YWJ{^j@E-Zl-8kr1*NQppvC^&k;V~C4Obe~iXaUn$Ipd8 zCFlSkGsVNwbsQ(x@?Q^S;7mRBTD|YbVMh+fEq%65j~9o;?63jR8y=nlz~E9Ik|)xC z1(kCq2_3*2#Nes=*m==uczUp`TuOBjeM%-g1?I)JPbsDo4f3YSLqid9sT&Brz!(K% zHl83BKiJd6DG&gAU7nf%VPp+6PlsP;()WE`M&wscGm@ru3?5bNYniUeaYNt5)a*7r_o*x4JKnrf|Ok)EYSv!!`G6=4IV;|-} zR@qO97fR!EHLVyW3@n`n>3S(Na$?{z4x%-Mal-c^6eZKRcmgk5xXjJBj$eB5sh(gw zks+)0%wX=2-*g1EHaGKT=#Zo^DvY-_XjY7cwsY+YJ+rE%HCLjArQvCcs3FF8A(P*s zpx_U7-wdIo`=E4?6Jw5VCN2j7HyycL7Av^Q%rU$CzpsJ3R#|zU=6Fv>#}P6@;YUN2VP@zX;iQ|Ey5*rs#HQ^C&RB zQ-|&t+4SI1xB-qnj7|KcZLrdktom{5xnHVNnhz&dIY5k8Q2D-NM|E~4-QBCQ^|gM) zF^TsreG5+tTWSui z*qbg&p&84blWt{Z(Qmey@c?OvrkxC&3P$~Yf%QKm-2SUO16H9gf70_yXx7u_%ft_i zzgAK_@$&NZj#i6_%_`b$tjY3CP#?h6;Q4Qoc-mIaCQth;Z+;_;o1Hw z-u4ICEA)h!!i50hcQ)EvUn@t9j;||iDXjGhgDWy^x2a~oezeK_5!i4BCCkps!qvqF z&-S-hE~+9J>a7}5hzg;nQw0KnPIz0PI($D~@5zpb*J(@7AfZCj_NPWtvrykUC^V1A z-c`^1!j7KHXk1&95^061akR_>iD_noQ$Z>CfeN!*-fg6xry`eU-}S~*G*ISnc9RR!sY`v(T_&zyQZ~?^lzvWvaR{G z2;y+1Q{E4eELP2NxZ3jnFyaxlN*~uh3Ti59`j_qP$^*}{*NrI99)_4!>{#*J?dyPJ z4JTiWd_A1lXMYNy5Rx0N!6!8nk*OIs?ll@Ldv+9V!iRH*{Sp=JZz>IKR}QZ)cIfu8 zUlm!Siy`W=E5o!C!43$M6)$|h6VP@y9zGv^G;^K3r>OAyjJ3S=qmLEDlm^l?^?drI z<`%lby7|SP7>Xv=c;z@vQ${fK#-Wfgpw+@7WlqAS!#DV8-eOKXmOgJ5)Ox>vDj5sn z6U=UCPBBWQ*&zH>;w!!kVsmNZ&{gXxY(R{9zhJH;Tc1i`5!yMNNGb$GptSMwJ@sb2 zW{-hHN^6Dp&6d1TF;(v(!`$MrlCC{^?eFf4@wd0`fFX$txpn+0D3@F54n65_Z2EI3(7EY+t=7dwHNzbI3`SvjT?8Mt z*d#I1;q0?V@<5vv#MiKq+^eeH5_CxZbZ+W)Og8|VO@V*%)?ve+A|N{tb6J)Q1<;vu z^Ze-W4uPPf2p5h#bb{b^mUe}hP8}cR;|1A@!rAOB>})B_(q>~5qX{LOHySD>8e|2e zU@jTEp@10U{EU90*`TsWJY?Scwuxp=Wb#syYwxt1a(l3(3bD)!m|TJP?nzpP zfES1A0(`j{`mIfx)XQsQ%_RtizHk5J-1e8BG=&dzC(=?91HOuN!F0L4Vo-qk#P}$~ z>>rvLJJARho!!nj43&PtSo@YeZ_d>&Dfq9E?xXH2;-+3o<)eRVGy#wU0j+Pi@rA?6 z-#;3@`|#4>yZh`6U{#qKd~RO|ycX*2rtIEQ?@MB*59-fTn(^98l}il0@k`2X29H0V z{l%2B!b-GWZ^8H-sao&Y7s?pkurlfeNMZ+$c9HI_o7ITu@f|I)ml(g>O7#uMR-B{4 z#8Dfb3@W;(fw8$guTf0Pn9mu(l72@JsfBBIsV+X2@xDpGH)}83^AOlG5w*zxSb(|iC;w0hRdN3k8U-hx(hZX-hj0ClYu>s zl@{dVp_M_NL-Eff`~NK>qFV=u<1vGaz=@pdc=Co9jtl3Pqbi^^WBf(A1^D=0$x;r`X>o>AVOWc3b4X<@*5OCwfctl z`DDUv80Wh3)z!@1)qjtXe zZ3_3i4qb_0Q~bK%yl>7&~A0Jm>mkq==o-^=58@>AFKlXF4mdQlw=<;Xm{^ z-$?LLoXC|a53n34g?*A~b{&nzF77P8C{6C2#rS5jQ#gHjxqg{!0zJ!o6m^@ZipfIt zz3OoS6FdxHu>rYDl}es?H57c#a~u++!M{YH;M9;POGmrRRysD4T@+n z^=1w;2yA)6{Md^k@M@@cyCUsQ((JX-`{$>yRY&|sgQK6g!fZAo#s3tuSTv2}Gs<<3Ey*?t%Ennk}0;5AlfHj6%YkO zF16cc&iAnmqyn-QSi}`1;doU>j8AgD0dqNnsM>vrfr%5g2gLjuy2-gBi$uSXQJIi| zYh#8)?HEJ17yWe0W^}Hj>Y-AJ^svG{w*TZnlRLka#%eX$mc()*>LRA?~f>8<9W>Ma91SLxs*R2AdfJ-EsdO|8a?Ao$%=e#8(p!?>fVQeme6xBZhjr}HLO!F^-YPr+N3Mas`LbA&RG*loLpWh>vtqri zUr+8+RoGcVRVXg};+R;h+l{-S7|Z^tB?gO_hrJqJQ?mRGqVlv6QkcTvQ;b^L*%2Q4@)RFkR!C`xkxs-RjTItDrT-VWx=HKL4?8 z0E^wMUOykPft?VBNLECWmIFF0iK!R1PaIa7Px7HShrAeGE4#UHcFQZnaD>!9_w8wB z66D~-w~Zw2ilAMCYhepYBLzAX@O@U~Iqx&~8(7*v9w=Ip1=hye40>Tk$sJa?*`P&h zNr>vVG7BcSPE6~n)nvMUyW(>r*7dD(o=j1Q;MVpqvu7!3R&Xm%I;GkhpK21;U+skB&hM)dGnQNa{HH+D#_cD48Mje$VQ7T-El>aoK*fYJN>y| ztb8}3Tj{G%gS2x28wY+Gh8&96aId6&!mUWUepE5XDObg|Vqtyg#cZmfNar^7qU`Yr zc=(!RRzmn*E96Y(6FXbOb-o+cxE<}?1~!Q7J*rTgPnq9;)w#Ip>4)XeRL=&Z+L2C# z;Y=NO%=9{*JRd?}5tCGqGDK!pTP5Di3JxAHW}NEua7DVmj6=IsvP)5xByvWsr(Ii# zeHc>{39n~4AY{hIl3Ijsr_3AORj2CAFrxFv(2SsdfkQg;LwUW569!{%;m;=36#080 z(2^NA98tjPXTdTijEqszxByW4!?hJGB2JceRy4b46l{oay*&FpO@t1_kCv@fxbhb% z{jZJodkl(TgC}%HnSM~anFraTGZk}MCC)7b+mPa0YNPob-z!ZFu3IecSTsC=JhfOd z>mEg3R`7eHh19aLuFVBv>wZJhil^u<%>-6&IyHEl8y_Aq_q_&^70_~$=r+@Q{UwKS z?qEHcI&AtT&I;x$<5dBUGxiuK;ARmO9 zCvhf8d+E16qxw{u(-w!vKm48a^mON7%Tber<+CB_w>($uM9tP}`^Ly&obj}+XtW;u zasa!bS!IyAFCgD(OWaZXbJi?=vALsrFT`4RT}6@Y-3^c6;-D`V)Dj!oDvLnBE^^#W zzR6pfG^IOuY;;t9Ku}#;MpvLC95teJevAPvIyJ^h;q>M`WcW>J(pk%F@{O6iZZGT+ zf9#m0e$Pq4C@+ZcYB;Y-*E3dkfr9gOF(3pUArS&QoRJoOC@yXYLxRL(<7izFPnhG3 z0jac{?ov=@=LiRjXhf=t3Q<0+RXlK+y<2gQ?;%}ePv8zff-uvnACHWAgubw-?FUyPoK=D}#v$Q@GW*2r= zW;R;fQ4g3AHt6>2Vovb zqSB>u4<%uLQC72fxGGrQq10Kdf>%q%k^HgTlzCodNI+C9Q=IW~&Wuj=A)(~F8cbzn z7(3c0gJDFDQ)ZVp-8PkH2R$4|ycLRi#vqLpE_ZrMfvO2wgTYD(?yEf&+oBp(HPzpt zG4wq0UxeqM12wdLgLJpvE~^tE_h$zBB&2@+Mf8eABaN_O<{>HlaNBxnwZ;U{($ zq?*SySJS&yHDS>K2!BdiVh{iuW?d|nThT=EEbENhjIx^ClF-&{Iv27qU30rm?t>N< zZ>?weHLEc%g<(+6A&MDokNubCV!EBBKLN3RZH|-IrlG9WPH4k0QuAO4i+GA8&em#6=l(k>m&yn-XA6!i`Ut$Q_W zXFl;ogSa#KwkuJ1Zb&H|*JkUh#=l&(>#caG#Z-K5Cq@QbT=7N1c!15CsF-7DeRy>&8T0YRqOH0io1x!@497CCcIv-dj85Zk940dEV z6sQs9lHQ6l`n5p^1zS?xbSElQ!?%mh`-H7g**DwR0XuX1DP-R#PpJ&D1-Kp<4|!U3KIdZ_bI;;rs%Upemr7t;SBX3ilOvO-$^c>&)x_JTA<) zg2hM;{oVS)(@~q3mwro z7{NC0P4UiOrL@PEBqn`i8^S<;}`G|VM9RVODco#0R@%awMHhsZ!p|tbgvJiynQ0XT3(30 z9v)RhjfrGEwlY$x-$rmRU{zU>0%T)Tv57$2>N#mHW}Rt&H;XWNvk(OBiNsg2ZtM5> z?eFVx1lpCq(Knf%Ub9>{M94MR3tjm7vpa9HcKY5EFz1(ly=C1D z<6FR2N@}L-%wz{IC-00(vey=lp+fcDYH!2=%#TM&@peKgKYRs?N+HJQy4rxm!HsP}qWVnEYHH!@vH%NJcbDbnh+ z`C)U$gM%Swha4q9ARKjIG zV$&iS9L^M`c;nSS*kXjNPzG`D!v06gkkfxJ(h9T&Tm1YJ5C`1-E1PMcZEL7rBY9PQ zmu7%5nFF7UK@t95EwCq!Py2UHBzlQp4a9=5gxRpst4f>Jq~BYmvc;sSg{Ez?YxRJm z6!kd#viX@0xfyOBZHXJKMwRoPedo$du2Xg6k8$)TddV(EK8P7KF<{6t-Z#D5XStAb zJzQtMaHOcOT$g>|JN6^rVieQelWT4Sf!j|lRecmeQ0;}Qb|ClrvS%6b4c$LUfbHR2i?-zBKPkki10p6_fpO&-+U)A94UyiwL=IfVOiUSX2l=K;|x?)t%P%AfIYrz$VSyrFD zm@Ol-Lnnkfvgh^=Pz4kQrIyTQ(Pzh4K|Yg~{4Q7@`H}_bO{%$s znP~7+XE~3>B8mI6LV`j9$lV-svOmilLlyc}#xXvX3%6u@kHZ?r(WDX@@)uCO?`wyHwhp%u4tSuUi`ty3@BDWmB~EsTW3o-7&>Z3upgtU4sSj5XKN^>vT$fQ%T|J+Z+mstJ{YtYE z@}v9*^h6xc{)|r8O{fBOqiVS}nbvek#7pii{5gGMI=2d3MCT>pz{X~i z1q1wj`aMX&q(*1CGklEIU;j&Y?|gy~hfW;5o0P(RdkU_zOg_r=gIBU~Md_{*hGx3l zn8eDU6`dAedvNtkEbY||L1Jy69~Y8%mMt}%n;=p9d&vm2G$}1VxdvN)Bb3|&x;{x| zt2<+@;?xI7Mo^l1a7Ub@bjA6S+f6tk38tu9h zHG94pcEtjq(V-DipJx0S=$@tYTM;LC2!+L-L2=IQGm1|_cT8hfa;tiQ771+S6cRgZ z*JB8r+Xpg$+1I5uHO1~bKylC`11*fAEjFPt(-e%$yZIcJ2tIV zMW8NrtM1mj&@zv58S(Q#aF$WMkh6;+}fKRdObRUgn$^-giKcl=jM*k9$Z`anU zycO@hM}Jfq?vbRom+!PUoaD<_f+_wT=7ptm^Qy+QH-K5>XOa_U7T}w_MJEQau0s8e zr5qExfyBj~+qe>~LH4(Tj`Ezhwp4{Go0ZQ%xiSZN-^3P{4CIaY_7fTb^6dbgtS#m3xL61(nnJ2EZYL%1WbgGzQ~1gLczk3`hXT6ArYcY^etv zZZXxpeu`ELGWP^S$-|!glA;0hENisKr--=1PgOqXZLbFPccK>%dn)l8J;!hAz`1*A9)gBGOd z+BihsHt5x(33jLdJzU5G8ZrMR%~4@aVou+Rz>rb&CCsiidRBe(Uy_9HL(BHI4_DRgAx`W(2df#g4 ziA#fIY}zaTmv~858?HNx!kG*Z#Fct<-n@Y=g!6cp*t5O5=onAAkiwzuC1vX5P1e&Z z_%y~OE)b}Dgzl+Q_zb=LS0wO%U_n~7?d7M2G!o6Dkdr?@t0q314(ZTuas1(L<=3D! zw^^$P_AgO~nQkD!kbpSWvJg5tnLvCk2^b9p483d3z^{Dw1aQNGK?5jZ1!fL_N#Ld8 z*=?f|0BDNNG*n#$IF5#FdkZy$dM$_z!k7LoV0qIUcOfg|C&!?y>-=uL8`yx3+-Bc){AB4&UUUk@Y#zH51GOKns% zl+fnT$9dJXvD+bbHviW-K(CZ_8()KE*7~)r6t>7Qof~QOtSqDUtUAA<(P>3n#b*#* z!uc1+{AX8r5M*|?WY!wLY957 zZk0MDwvPXMHWxol?oLYK@Thb$pGKBQj7sWb=jtjegF=?ml7kXd;)<-6$y41V{b{BX z7_F06q>SfTw8wkYm)@%Tdx(mx9$`0f0+xu->q8FB{0>EtuHthY%+sXg>d<}bD(>&T z8Bx~$Z22a;*d6+6S!?aZ2RYUNBDjIH4XK{h9$nvY4)93ynY%hWi}xcQ`zx=>1FZPA zpuHCRv#_m6Bd@E)jQqCAI7@@euDwL+D6AaIQcuT`E>+Hru_#>uGd}Hg=((|+{)(`D zq{*w7!setiu5b&|f;c8plF{q3jAP%{sKO25^~)vt#nc%zsr|#F%JfP(MmX`odbxkc zLl0h>wK#i`)enEm@&(8SldOwOGcj$}2Mq5sgry#nIRGXQJI@Tw8nIX?j| zkJUd#?Ekd0M0*7hX#TN90dJy>Cwkc4bzX&4|F3)_I8FsApcX3sP9e6vTSUXdD zDQY;9_HpOU%j<8ho-P^6;TZ3^xfu80fm~1bP`{fH0f49v-{-gu2MmyDv@A|5JbZkT z7m})@BOaz@Eu0;gd>24*+R7XB%CkeA&Pe}^pb#p~ zx2LgUPAM@YXtFL?v1kAX6i0;5t9Zie+ZhB*i{|-(WmOeCP^ri2=(vnf#swmf?G(Sz z5Tcw@V>P$2_OWU^JVW!RJI8K-3_is6<|jhTTnboax?=NS$Du?d-@|S|Txrb28x>%T z^m|Bh9j4lc%?0KH6TH^iG)#Slf!xN( zEi(_Cp;tR$){D-g?>ZWR^rdWSYX`BfjhGE=hMgGRrGM|Jg(cuwckY;;zm8X`Cj0gE zUXtD|e$lUTGekETGcK;ibYB8bcLfBVKS)gzrOdILjxZ%J25cOIZ^+aual@v(OHJa$S9 zAYQO>K(TF7k#lNi(4nK5iHoW`!kH$|_=~lXt|VardM~N1xsFb*On^&O(@}}(u0YYo zm&R3vzxNEsM)H9d+-p&b0tYRINb^50ixALzxFC7ebx_nEZajuB`wOz3cNHoSL%fCC~8Zdd&Ih zLBF{&2l^;H+}{tL_M!N-C*C(d6&KjS0H!I_%DUAV72-4`qjM-kuJ#yIM!ErxnybY` z7gag>PK1dp^+(+Y_y~LVw^e?=M#V+fk?sX;`69;x16@6wiwcNE3>;w)HOd= zAfh8MM*dzImU0yxSZw$GYsr(NpF(D<;9o~7J$+ht#) z6;bY_9mFOidKQ*IJY@s7ia2=i%T|EmPq*(Nk@{7Xkh>^nht?$TZ2d%iouYC&?7&c8 z4l$uvZ$+nya-B3BR(|yzB>?4Qt-}@eG0%i`5fcuB7c>THtDkK!pt>b)FvEb^ zue&*%4t9%KX&X;E_6i{x0-BdMaIBIV0O~++4_?qXESgk#T{C=$$1Qye2y|}DL}*tY zVi6EljN|*tPRUB=8b~g0Szg$o0tx4Qsm!-PoXw$eh}+--Oz}iqzFCyJ1F?1#UsD7| zlkvCwJR)W1+OP33ndVcOy5w`QWuJl(ZY_=3T%)QYqQN1RRMe9obyqaID>!qk<<35n zFsaMI(xCd%?s<++Z-r{J1Ad)B?}XJ-F%*ccIO(;!t`tY}9F5G z7|5Cs)8-NH4A9wd|7Mk~@yT8yyTKXP$-G>()1;?a2MGv3Z0S}AE;hffu4bMsFSl$h4lv3@@eH5EUSilpxs#cTs=!JB44j>E~qR+RGxda(R|gaqpNIB9J|mxm+3?Z>SwiCF7PeG z-Q7%YT)oPuG3VCTuG;jE;gZ9#4ZwLtX-QL4Q{t27=#ynfsLD5&rK~+Ak!{rB%^D3V-o(9D$7|!B z;a95`dxdd@o3xN8W*_jtrw0MlL{eKzG&LfDHu;-e8AmMq8& z27FR}Yb;j>_+3yqekHX5<~~2pOE2Pm zC;Q6cP$BZ@@Sh67QP_b3Ucu8|F5BE?PtA1qo%n+Kxo+H;R^FrmpG;afNXXLFC05dO zKP2kvri-}xaR;xF%cd62@1dYNJOB!Iw23=;r>YhatAS?(l)|FhWX)I*TsCIc=96xe z^{c@!lZs!?!muF2ic~+Zb~u{14WIDp=O_~EYc;Fca;}4&k%jRBHKW<>mX)vO@}H)d zGy5l^370U838tFOx39nbMzv^JJ~fI3GQ!`9m}7%!T>4TD-qwV>9nMX9I}gj0UGGTG z`Nm07*v!oK&pskep4{z@`dN#xdivVvs|4a+Y2c{uN+~e;X(qH;@xXL$&)PG z&a8gQfA_+`J3B3CcPH#Z?y)P{5pLU3cAQ?}C2QUIaH;3%HLS|k~!}DW5@bSu}Dl`8ort_f#BA#MLn0^#&>ziVfi1UbJ>nR%D@-hb}r04nrSSP zsycL>%ET_Qk1c{66%&hhJFwsv>Ypu+ADetUMj z&OFc9gA%q_>f4Huji=qe9hXTHkmzq;o)!{QXw61GL7B9@3<_8Pp5&B%eMFF-`c@jc zzO5kKh~zMHYJ*~E>NXV8)xE0CTiBk;NRm|sv(l^l*)9%k#!>^v(i1mDa1 zeg9vc<9{5_z5C7wjkUlj&hU1AoFgwe%8?aD`6)9#`5trp3ATt^ zq8cfbE3N3YfLLv-Pnsk@6E5|>yMROt=H1}2mh4;eZWMOdj9ULmsG98mW|N*Te^^4X zyPVkL2VeW{_r&g^%%zQXQOjSg#{uKTI6dxbW6ze>ED)i6x&IOVU zZWT871!ApP0sZ>f%VC&y8ZlRm*gN~7V^NR&Os@X2AY|uE?D%q|a>xTjhD2hzg1YP9 zHwm?qOTx>g{w$2*Rt$MdB;M&#yiG|8-$`w8v6t99sBloTFrjGma6exsk;G+d10_bs zS|74=1!*dJ#@Tx+j2GAc&<=Xb2qm3|{iJ3Zt~OW%v9ygHY`LDxyQEg&dQ2Nb$m!RF zX{XyXhk^lctil@HLhLbh5x6DPnzXoD$LOA12$e%#i2IEy0#KZ4n~?V0#8j}th->Y~ zjeCXR(^@qPlo3s!jKI;&bjZ58U)%w3sV%=KKstF1;crMf-CdmJE3OPtYqd#RW=()@ zQKnCfl940_ffuty8R;fG8HWc%ljtdg(UOpVfN}&42IO-gQVR~-Yj6K`b@Y!7?$wb= zR$kYXCbPhbHLp%Ae;n+m&YD_By`_0&G4!)UP>U#E@=z$CQXqcot@DQt!um=Ll1h-k zR9x6mktleru&eVHWd}Ok4Dlm}_m*%!yT+x>AZfT5+es>E4Ob>@v(7k*itYwgT}-m_ z{xH2-uSp#;imadp+X@#AfIQdnv7=1h-$0bV&?+2Y5Os{M%ma%y&N z$D;S3(Gf<3-Ao@5lTstZyR9_QD{2rl7Yn+BY|?PqXO9-jR|<{#$MpZb!k+B81Uyr% zPyjlX`&wK7zo2hF!?&MNSJ$5h@~c;8x!Pwz(ZGH4xm0rP&UVAYozi|0PeYtfNkhAm z@z#=S+HE{jq`D{$!6i706yoqY5-lf(Y^cHrrYun7#q~2|eyHnZwrIr^pfNzA=gley zCD(Grp8*Ebk15Fv+CcsI^hP_N0Y;%ZQN%N%%Rr&ovI#zr2k_(VS4J;2@ln$B&%87v zQGyofF-N2OJ3&b`7^v@>=fc~sy9)QAM)3GSF95Z}XeBvAa_an#cw}Ww`1r{uNo2n1 zgYZ%w@z{~<`sPR%R_=#Y3uVY_dUb!0)FbBh01&eNM-==&gN_6 z3yvBEu7b=+0vb1icPe>ceb>lz4s>IOh^DN!cMb7QeCfIub6sr~^kq#(k*XC%o?0VU>H7$H+Y;&vCuwx*g-&v)nuaCc4Z)qw~ z`F~;u;5q?t=0Bvi{_hN;-;oWLR12)R4LCG?Neu^ZJ%oIaMN&r8YAmo3#C#QqnkbGV zyH6G=Id+WN`+E5p6NTPpA&crOS(SRxkFrilM6GF0V$IlINYD47+oP`Ft4ty3?GziM z5F214WV?b80u1_k@I|%2l)qfEY)Bn zdoXn99Pbj&8J1J%))C(k=&g^$srWlVSa=TxK*}}Sv+SMSyQ>20?6}H@>Bm(@|s z{vWvZ#1mL;7vnGg1(Zum1f&vG!xo)Sj{orl2O0%6uAYDE;3`DlerGgVS5B}HoM(ok zZA>eBWvZXwoIAa$#0HzAP>HveHz)a@Sd~E0`c;tJY^|)vhUaI=v3bW=H6G`=tQzg( zo7K zxw7FRZUL|eSQf($JLw*cSu11cEjRbqzk7CNy8UVy-~@A2b2 zjp;V0mE{!Q&pTCgCAr_Z$C-na0P}U_#fGg{ZhJ1LZ527r<)w*}Lgc+aT!4($)&bP7 zAeezj9da_bV=`4`x!g2#K402YY*_|=<92Jq2!tG=<@F<@E5qTa`?v(tJ1xdT`(aNT zwkkb5z-~&qOPsuCQ}g$E_PJN^MPXKHdQICSH|MopKpcP<`1k{QGj1HNqXX`n!`+zW%KpIN;I1@L=gEpizQ^p@O2$vivBN-!2g_R9`dF5N=Koh?fm7m^%JY( zV<*t`6yljKdy|H|^ZJfUTc`}Pq;w4>Xyvp}M;g#du0EYi$ln3j7`F2PXHW{lEb;>Y z@B)|8+N;};kt30s$O$y?N8l3TF@&fox@p@P^)str^U1HJ$i+6#%y)s9+aATk9>O6P ztgHhx4mts~abe3d4$>asgF5&VCCk}oiFoT-BkjbzzQ`3P=V`W^OL(+RTK_L65$XFJ zXcFVF99_vU<-G<@B&?Wc=Pab!BUu)1^Y!{!zD;hdTI*>*@aKr_FXUba$YUL#>*n6< zFFM=pqhbP{|AiCTt#~z6TNA)pM4Kh>I_Y}fYz{`>p)+@Yqmef7mqNK`YoiL8+hb$N zYSXI$R6kIlNgwJ7N$5iXRCKOkXgP?HxFjnpi*!QbI@j-tuAQoh3!bmeB{Wxhv`Dkb zW!*lzS7T<`RKn_Y|Je~NeO=a6>^KPuinRQ1#{2&%t-G_lhhK{q*iaxA0W>|@$aUQn zFE!EMUub4x%lQO!$4~2@s*2!YE8yFx&ges4= zodYnpe{4nt=hpKz6zA;o+F}zqU=8nzX7)9dR=`O*AeJnVK?-{OgG)$>GIT06;>blP zI1bADiySrbrZ$GBbUmKI3KdxFTCdaT_nvL@mhRFpKqFelzfuyr3b>G$=g1k}x)8QK zXm!rGWJ>ex8U54!r6~~!t+k7ua|U?4jpgjy{%oJNk^6(iq{MX6$2JEG#B2PzPV2g< z$@t#oy#89TG%+uIP5%pHKF4a>6V`TenPaqCn(~IzJ00k>(lSk?vZ?{XRI9X;;85k$ zR-gM}B`Ng>>NpYy?(ZhCgr|9(sgPhuZ3UQ~1Z^84WckHM!ZVIeSio{imW^(jY^Hr` z^}Q4sP?I>}>uSY{9>0+-`rtlOQL4!D&+?t^y@NW+bu98FQQtFs(g?q@qHTNGF6G`g z7Hgr*$2SHp(UF>yM_n7&Qe6}&1M6k0a!gZ^?oY~Z!I}0~8uB!OF8B;HI@M6x3_vgA zPfZ;JhNN7)fU_$lP?J6WHdPTZ5&r_gPXrfG?#u8KujXp3o*SRYhnWIIkE7d$acuWS z8@4;y(cO06LIOX_tZEsT+zQy?^yM}hKU3ErO=aJCyCSdKixJ=_xobA1@?@Cxs-T$9!|O$8(Am_Fwd=< z(tDeP5t8sn0e>!3ABM@{H)>y;;1oG94%gWvH1-+<;;rH>tjB31;(^G}*?2=7ms$KK zkIT(Jlz3;p@GqkMk@XjmqLsH@GVFSKWJcKfh=0Ojb#qUkyN9m_o9DC$b92Ud4RI6a zdp~M1dd~N1>RERT=qF)NvXJp%o?jWwfyEC`e^A(rnXSNZ4}`H%)yRk6v6Pay*;E6r<&L!>BG^|;C;A&d`QbWfo%%Km{r2bj6m@2E%IjPAyUkv1qfwwJhDjiZECh8I!Ft(XOkq`$eebO#b{7I?Ns59khU# z-MLOdl>X|loJmmMvYaB4Y_Q*fr?uafb>D@z$64@B$0&?pwQLoBzZa2!_s?PM?9C_> z@{{^*BQc)LVj)*u{|-5Khxj`0Aadi$V=mIK`+ruc|EBf0jg$~))5G(_O{G&6PQkCf z$0R;hq&|Ef0%3Mgy3rOE&N$br+k|kY6$P$r*a6Oy?M>E-er#B=UPW=uC%q<~VW<3O zOZEVKnA~ObWC3W@L{1p{GcXnT$V6^8{@t4a-#jHe8Yga_2&Yqh5M;$HVI|&J1JCi< zHPs^rbptGC$PPd+G9DvnL_?fzyR79twLy(RDXW1;$HX!g7CVqw{iyOmlS?!g@&L62 zn~(qo*0skp0NJeB5(ZS9OQK8eQx5&b;vTodIOOy|W0gq!akB|QZ+!(7u6||~{vi^g zHN4%*#=zxKiIyzC+i?Y_M>)L68E8(>s$Hu=HY1k<8>3WZ8fy|2nIobfj-E^v9V|0f@g#Y;uA2l z^|6|eU}l*YVr_y%c=%OHEPdm*)cGuvQ9bpQE3Gz6T`L{e_n6$rh9qP$=QOj4qKRfx zN=u#*Z!M*r4^Wm@>)L?fvJJ1)W^v@0u$!WzWd%U_8@;~4wO^M?CG~E1v9d|2t=Ju! z(E25)%i~ve#Tuc0ax%yaJpFKBTJ(-7>d{ieu2cp0m#c?%_iGj;z*de12odV)IhfY@ zRxY&FrhvA+L-;73qrp7m-8bm9YUkbrHcY(ujOt#3b1w`&!hi&+Bv*#RmIn+~~u2ee%F&)d)Ra3r_41a`pXMfOl(O$GgtDi&+=f z61@P?*}oDpY#faS>fVKC>cnHB(hwL^g<`zwQh_h`rgl-8xO^}3fOP8=yl-9XZm|Nh zD!&H)gDqvL}A$6PWs$xw>@g3RsX5WUKa@3=8Fo zpx`I>oQCRg`%s9Fc#(IvyN}DbuObp_Otue>Z7$NDtQy9VR94S+dzl`=tQI{2zaCM~ z(Pd0W%@u25g5IK_M;v@BR~T=}S@cpCRhy>AxD5RQfGmMf2FGMGPIa_L@jDC!JV*ce=87?*Mv12Hj#YpQfO}evOCK zCI7*XLntOIoL+5B9un<+uAOO!Vu2pyD(PPn)K)rd3`nu2j{~m8ZcpZ@UmLxbGi`!a zGZTODs6o#slmzS_TBXF7Vcd>-`&M}#Wo0{+B+AqKD%3FvP)3|{8?ov>S&MyiByWA+ zmzJ>k5z$58nn9yaY!DB?*t^VfB0F_D&#kmaU|_@nDsh@Kqf2a7MxN9X8+-onM z0ATI^kkkCH7xWzs32}XHUgV>gkYdhtX2m{esAN=96KYoB*s58m4KM?C+3%~aFF>jh z0LOJ>_ns|xJGuHztMJg^*1Xk`6CKNMU7|N;4Fv#(R2n@ph*Qnn+NWTciuZiVPd&Ke%8u(1FwhL#=yc80dJcnV( ziLzeCVzNH5FRzAoy(Q5mp3v!A&(7@+zUK#78Wuif>Z1ECyv&Z&z@4&S7|!9MTri9q z5y=2V#R-c85gE<`9e-A$L>3rl9OUerR_>0O>>&*HW%bvFW(!)&0yNblIs^T4Xwwk^ z`ob8epqMp$l_o7B$hM83iD}BqZnOumO?kh09h94k3kR~fHs&=*px~#!^shGs*tJ7Q z;(~`npdr&bRq^&!XZ=lvV+&f8!<6`ZHSJ zDpUpv!NWZpLY+N7O$SpY{Xg2~6x79vpUfgAsST8hbJq~GTIjA1x4$WzOqdW#7nww z_!*6QGT~VkMRyZHsa_JY#@!gT(v9*fh#yixSj_r_meGoHNpr$<~&_7@*yr5 z(qB9wmsSnP^>xUD9>K$IZINlb8{m7<(2-!kd)oZ+YMR;j^f0zNwVT^}@f7JPw9eQ) z6_w{&$|~W61LoM498KciyYB7$9d&mx0FuDyskut4-ad#&O>P@7x;Xm-_=q@%W6dns zI4}yu3rXfSuM^Xk9B=LNw%yLBj2W6&B_oUl#TVGYH9^IMf+vVPK0svy=6Uxg7p>Fx7jyoC5bp&cu^jGfymu#=s2Uf8>$B+h+i1?Bz& zuQ^)PLOB}U1&$Oqd1`jNvTrNuGknvJo`&g>1m3Wc&`|Jt0^-OXfj z--uh$=C%0TeZQctfDD}cDuU+oH@m#C@X3NCSBXJyWI6w1PQ$AjX)GDp4qr;Ha7wO=u*y3Z(QEI z!=Jk(XsAEve)xG`aw|`!8XZ$OQ{Egm(>9eb^%!t4OEzYVge46JrKkX!Y?A(RUjPh) z(uqqVvC}4qs5JgaBo`39_Phdf+lQ;p_M!qimPdvnBQ*W@Pu^+e@=VF;TDC$jC>NX` zoN2|##$hK~%S;^0E~;d}#|D?l9>JpG$#&bS=6iY2yhtO0?4l|^l} zdIbS7T%2vL)Dt7uTU39mwysL?=q+LW$C{xFPP?lTu zZjsMIaNlf$Xt!1~tp$=yRmx%7bkEWK8wGX8i~khVD-Fm^qwc9--%1Q_HONHcQDvDC zalltS$v5YfujNv?^^vp)7I}!^;Zo6ugWHz~S?mu2S2b9XuV)8`ommE|S^<+mPv)t( z&kH_>!MDTw62C-Hr)lUy)0IyvYlr(~?6Q{iuv#sB5k4&$BY{ThXFD-7O`x#v7dz-} zW$M~7>CQhf0SvS#s8=JQX#qL|+>(>)OyF}Jc>j4+tA zYP@wHj;_7vg$0E7eyy!THDwVl&Pyfg6K>Y7pc-i28Yfh-tfp!>>3pY-MST1Y`s>Ax z+6eycZGK-1Kojcqjsc~plKvYZ@6(g&xO$X_O#{JIGm6eJ&^ViH;ld}a{_HB1u#eWm z5ch6gklzuZtcfVVM;sS%v0~GN*cldya&zSQue6WdfyqlZDVGX1{C{J=YBLzU1`n-8n>Dn%`zb-u@RP+70rKriG05@h!BmrC{R z{B7)O8C-P(D*|#9FDuQ69@SYHHxXLgXW0yA%FsR$r+XGF>m37uk%`*r4J68du~a8( zuAQ@6QVP`Dd(5gfO@g3>(KGb5j=wRskUR+}#c2FNkV;dqGcP|yJx_D(8 zvU-7KBMxwWHr)8dTM4Zo5)B^P1lGC@I#@R2JKPU!O|&Y(19}lQ?GU*H!*jAjI)j%1 zvla5Bs^)GJI&J;>uw&Ws>_ttAG^UrzC>jR78o*EVjxeCu>jbR_E9TzbeCU5Afc}3* zrO%cPdCMX-W+o-ioSx?Q0h8DeeL!3XKxm=~K-bT+1IV;Yu)N^>2lDHYQ^PLrI{-4` zuA%dituj_^_3Fm_@L>DGF2bg-A+$Z00$UR0rMuJ8!!mZePi_rP%o}7zbAC18zFW+TaO>k;IH!%Uz5f3QVJ%XAWm3hH({MOj4RpuiD znu>~Hpe+c(8Kf;Ixuk)68G!@&;kPjb@w)S=ZkFxIZ3GCU>Y=Hk?8qcc&l3gN-oO$%-d$~?= z32+<%P=X8aF>FVyAQC@gI1o5fkY`_*u;_+yrI@9Xw*8*a+onMl2TV%q5CJ5vFi+~M z4^=deL_(sLZFy@P0?3s3;ul$ilDPB9uoJhBVrWj@h{*xQdgS`LioQ#29mi|~yR-x@ zla#8jValI~Nh2U5VhJ1EGFm-=y-rDWS?aW%}G6k7x(0 zVh@n6s`*(mt(spMBS+WM-_XpfQnZ%SsB8UTgOdbP0ej1|O2rl{Os~M&N=}{@mt*Lo0fxI(3 z2uQJlt{)u8_(NkOUk=6ueM>r{j;U_H0S-81>F>XD(D{-0u1AeZxE{?A@Z_NL@YXZM z6!#-2;G$;B(VS^`ximQ=kr-}CE(t8{?TnpC;=1h~B|Tr%VOAJPWi`x^M#oIfuIEpZ z7RKxoFKM;&sY@1!$ik|#R;^vOvMpMK*w?@KALCrluE_Z_^%a~-nk%()c}_tZs*G>> zEQ_pFuQWvxJci@s9hiv3{^`7k*e*>Q)(e1^NfUN+>w4?GL-(N$@m2_3&LFKORe0t7 zKC1-JiAUGzu^$?1JC*y+eyHyG?!*K{y%VdyoF_KHyeCwBEm_9Cx?uB%x=F)?gKloH z>CCk6_>szbeuivOe0X!H6vORrop&;^QvY6-`8j8MHu{_36dduenT4J=MI>(pn)T9m z3$o_e4F6LAN2aB)$#BD( z&G(Ks#y)F^zBL(1*{s^sC!{y%72mhEYMTicASN|ogXGF!)aL`=jJ})s)U4s&bAG{J_uoT% zHTm80N~}I$R-vkqiHxoKIY1h4i?F9`3Iq_+3{WAmmr~IF0LH|0Q;m&!POOUK}+txfg;D%>W?u(fP?wP*AJHWE_B~mw-6ivrrf-fX23B} zZn!9fu9gR|j374(NhKkgW+>yW^9elwBlw5KJMmPC%cejTNWB?nQ~JcitIZ$&5glm0 zj~os-((lbsxyNbL71G|co7kC1?>W*x0|$_A&#A3h&!_%ro(pXTJvbU#o8QvxE#3rR z%?>32X$hjX%=N23yglhge&*kQg%)QQOg(2-9_n>Vg`9KJ7X(v$fNX(Fch;wAf63?p_ORt-cfOXJ6F6GZ2enoSNBCNfSVLY~#^gIt%hZ=be|@new3I*u$k(kns5Sq|~Uz1%`^D zUDCD_+rO&%h+UZ1&@gjsy7+eOtG~079_M8UN+%L^=L}{P)#2X63-6D84&6RW)iD`U z-t)$6^uaa$VpX^zef7{V>j5~NGhNeow(@e&ED!RPkLN_Rt`nPUET;baafu3H$6q+w z^lM+&&3X6F6Y8tRTZ|#ux18K5ZJM>Be9a8*j|X-A@pQO`dcy2We22Ph<~~Eb#+jp7 zPMYRfAPc1#j@2$*Q7L2uFN9A$Dp+bi%i{P1gwR59U+MtrG^mfm9trOJY))^E$Q^h^ z?ruOj9@j>Q^m5ym4X%&yuey|)rxOM)M9#CZ>>c+%Xq}7L)xYAXaeP((C%(r*k;EFX zXNP8A;yGl@0TaiPN>2xM4HYQ1j*a?9P-3c^^=xMj{Vs+ad@*=uTgny4>@v7AFPR8h z6~=lDrFvyGfAC&Oda4RZO}juAMe2@V_!gK}p_%_M?r1L$HFewSg2EtlIm;3yjbfzL zeptQtX>?K8**v8q<2+4G1IuVcApJ9wG~}#Dcx@{(hK_fdbkgc?5J_irHAf2d1w+FA z!^i;3SF8dqUupX5S2yStY?+ZU%;M8@TZeX8e;JR>i|yw$Ij^@22c|mr9VQnb<<$+O zyj#+iEy0g}Blz-j^!Byb&ZP^?5T@@&RO67S{XBW?8d?y{m({J_cMp0@f;jOoq80Va zIz{XhnakN6r=js1Py`oWiMl&{>IR$Oc;akO!uS-e$N~h*nZHlsjgM$& z!112D2E%-2h0oj0a_iW`IN$y)vL7MUqfg zZ4Qu)6nT|*RBGMAvL;4xebM?08 z$Q@McpUv>qpYt`;sJYLr1MsP?avw%(#V~lJB_Cuio@&CrB~`IPs|*xskE3! z?eGf@e&uUs(YaXHM2y4Cc~^%Tw7SowL z?6@)Awj$B;!}QPvpfyBBm=KJ3)3BWWxXDAVej&rurTJ!Gko`p0zB#A`VX{@rsoUTp zqacyYctq0x51ZGxL~=6*MZng?>GD=q(ZQc%nq>u-05&2SlI#d>=VtmA4#C$@;SZ)ajNF@6{OI9GJ&DxhhFy7a?p$SyxeeA#{P)yL}& zI?mMg4$|L4MoZTksWY$wK;q(Q$7n@C1HA(S7ndXd8L9|HF*#174S^$jn9{G__&bUN zuwK751@w1WglZF<#NnDgBmZdG^RyRthH0Bfs4jO+VPrh@tOPd9bg6;o1aPeQ8Gz~U zZzX-KdiC}0(}h2f2{x~OyvkhPrL$g4kwFKT1*GgQvzm(%oSxVOb=Rw6xg3TQlMIKr zvyKQGtEZe1y-{|=@O7{?{zPyrgIQzC(4vD!SfiiFX7!Es{#r26{CWsrKL}4=fb2ji6wO$aACep*L8i zwM7%4Ml0W%>M;v_d)j!C{&EwbPz#v5eLFJgXj)sW)Zji*bm&WG3tsY975MIf&Tq|P zJ_Q6AB8k0#`Bj!oJ(3zXypw=jBvwt5{v4~AijuP?+__J3PEZ6A?9^RCS03J_BQ1~w zI$`<)mhIvc85hRCfq6~fmqi;vzfTR#Sg7}xiSAQGXb*J92;$l{e#|ZMoAY+g8xbCf zMS+Lum$@_xRDjHz038X%zC`**=gf`Fn#kaoNPPPVy|{~MwR{LxJi zJJ^8xPVO^{^Px;{j++$z~QyRhICq`b$%NFG7@Pw|L}spg@r{`uSz4=J%&-%8~CHqfq{XJI}6_U z$FEsQRNY1%$!P&nrLVq1etB`h#UH;*Q|RQc9zZ6k>*HnAx(j@yyZzx${GEKs^k#dv zIIFw?Y385fbkA}i|9fJ3-Bi~|C!qX3H`Bm*v7Sc-WXrz0&PTa`AEXUNy_-ECF$Pf$ zF8Y+)J)whbVGYVa;6jdw{m#7J!s>p#w-i4xPMoJ0kc)|UcU>%hW{RFnwuqXy%D@7Jbi;`6fi@(*u#jN|l! ztUD?QojFV1hetk&&*%D;g?Z*M5-FPbe=Y8Xr=0H4dp{5p*;3Q%l)H}QFermJ-7gG< zblB+Y+0|C|=$c%bS|c11-q$p|F~5^(y7qLue+0FM5Z3u_?B#TDhfb_$tV}85Turt> z*Ay`i<#Ivq7f;D6$?owgE1Hjg)gA8mJ)$hSsj03l!u;YN6Ze%yd2#DTjTquws2;bJ z6Am?>nNrD6_>CFttyGk2*80$WgND6BbpUCC;j{o*-ZFr|e$AjdYqD^4RTQWe2V~tX zAK><`UX6R&Fs%f9X&oz(Wi&vLU49b?M=zzxBG&GXEU%n1*0@qrwUJ`sK!Vpi=WR-W zoSypnBF4F3*cwz*w|kEC;5s=k(DPmv!=5&4Va?KYun7>pc}4b@&w41n!(P>)Xq@uu ztWi@_R1Z#~WgMm17B;U{s1hX~S!)Q;YhCqJ~qtT+S8w4`L~)CYSWNV!~z z$;-$+waR=0+Hlx}b?!bcA(54IS;GTL%&G9$-flA5vVXee56DL;vS)(J$b+E|-Wq|* zs)QT`XYobO4JMQ#i^lYomTVq2s|{k@xjRrBGaJWRCH)g?OT4`aV)66-Y^d{GqR!AM zs<0_b#2|F%5^^qjZ?lvS5G76WhGNktdON$n2B1a)laBxnUPq>EH|(1wJmt~(ix7K6 zjz&7cK@>i%XX-Z3g^x#!*y3KZWi$Li!`Eh-zFX2HB{MiPQ%{AT*!cO_u(>GOnctGK z&`^)_{CJ02Wu@~@x^l1QKLcYch-=z1gXb<*Fx8kuozdaohSU*KWCnfZat~z^2ZgIKLYzN-@2eG zmWh5~DK?ncx9j9wTvzo!H{PE)eNyS4$NK`m?Yel-QF5>3{wsSPwR7f$?742Npp%_D z_cr$}tj^Gce5<#}Hk1`L6J|r6$SwVqMEORM%;i7qE$B$*gYpMIv-NnMyzNe%8>filEc-_T$ zKkw}1K4@Yz9`bHSc1edg-5gs&%Z%?EgDKDDoQz)!DTm+_+@RPSj7-egYY4l~!`W6g zc}3A~BTjw`FN2Z~wVz23NtEvm{Mf@&y6GyhDEC%fVjeC=yn*f%{SJ)&N_taHMXJ8D z0Ks%dBbpJ=QxZ=QQlCzk80M|*W(xD|3|)+m9|LqU$0SWJAANq1n6C7BiI@)l`7}ID z$Gb=+T3gP0+MF|4+dQmE)7GBM+&O_REgD`H)AlrLRlTK7Nmj;)YGnE_hPtohqdxuV+Xs>+{W zpD(41roh|p{qXjp+51Un?_cj;SIBo)^b9qCu#4A6mZV=eR)2k)w+?kI9 z{m4BAZZoj*Jm2rTP95Jg@0pU%o%YTD?mNxWkay6NQ2X~fj6Wnk7&nk#i|aVU6j}3& zDN1|Zs6FY>{8@NRfN(lKZ1)mC)IBc$GZS~zVUTSnD$m-DShEmaZ>$*1^eacQB4OHyDdU6pV>GOn_C zoY&Q{zt`8!|28%}`@rEc-RX}ehZna>SA9_MJer1~@uEW9|U^VF!)bsFxs!;t3) zUt(3{!z@yK_ProjIzTFU-pDF}OG{{$wC?XL)s*Vzv zQ(b*6;^lptbb|jwkZF9SpYui?A$u@LK(zO zmcYXoedF);F6*`z+EMQ<;h8Iu@T+k-zT2OZj!$?}c6sU8{TR=m0-IrdFgmxZ9UG4e zK7h@I*jmF(4-srwL`^j=pQabPn7q6d|CsmJFzq&h%@oJ#tLvA3Gf6l#yoU(d^RF9k zAVk18>v7&XqKNZ1lVrvNKD^u8B@k{acbKsJ`D6ntr>ST6E2DrV{bl@EveEzEXNU9# zpAu}YzEjJ~RgMwp6x!^N^^X_Nchf$U`k3fby-CosD0cX-mba&obBTM?I`;pHKhRyS z5x6DxR~XH5ss26PQ%7~pKSQJhNaBir%sBDGC?r&^znO;>t7BvkS#U693>Jd}&O3HSs0xVuz}# z+k4Fbx=XlK4VDf2QBa*%8~9*1ixUq63tXsS{#xm9hbdRB*Vjw?Ge-(9=Uhi6RlP78 zX*rJOrLdUQznp#Dm0;ep0btp>6Y_a@RPxZGBnJ^E?o+@&Ec^k0)Ix77j&ojG0LDb>mtha6a8- z7S6Az<TnYPqhr*#GeoH^;lxa=5HnU*;%<*!MXmfFIrAIaBhjt(<4x*3L4(%%gxCBoPD` z)yNn77%5HPIl!%yH<;TY3?UdSy1+mU)jhzOc$VPp`j8 zF3xf8G6?}O{B$ahcIoQ)ZJXkL))BKD7gV#t-5B*BpX=Ibl(2=~v6s7+iigc9y2;6k*%PWv;AS?I4=X;0hKe z`{j9mkAR@G7WOUBOsI|iin(aO&dIaJBz~PZ_Xb?~^kIF3Mp1|Vm&TPl=}Tq9l*-=o zgbbGWrx?LJ$DFyDzb08vUhRhs9dU!t!bvHUbd*3oR`IvW!HLgB86o*SQf`W=f;+%U zd7fA}s{SkOrXqj&_9fp*E)cb_?DL7$8Kr;QpL%7{wQzE{Z|-EcCt4y3$0>Epcw7Eg+G~r-ZWze#EPJk4 z&yLEj>|9WTMW9D)4L)9rM0P(S__rLzkER7x*qCx;aVJvP%Ap3zM^9QV43R7|7TscAg#xa%%Uh)Q&o4mG1xWIQ@?za`^bAPsTX zf;DH+1Q(ZYB3+WcKJiB{IYUrso?kB+BKDrpw^QRB#H(=Yq1T&s<4u^vL;J-**090l z-%zvd`Ka*3Onopm9KhV6>WRml57 z3quc5O33-5yf`;3npILc49z!`NJ^!(0d0<9(UG>!KP&$=ZODs#Ce!=C&{xk5DQ#_j=-onZ6#lW~TJ^l!}ED@(p3+p31& zmh?H=#@Dic(nuKbYGudGyvKYKZ@VVYoc@&=kAI$B`77>?()0t_^lJvpDBj5;ZdUE{ zw5GLPN9^{tJj8O8nHo2d4V}v=MPEu4VKXwJAln+EC6K@}ktNZ*W=yW;G5c_;uiz zP}{2hMWl`y32)l>8A-4&8Mejt8~!8MW*UGxI+|W5ufVI$zjFi%7%Y6!FUx zb+La~(nVQbY!azs>R1tg?%S{2GF<->jNw2jWOGr%spETnD_vvMAz{l>hP?Sx`d0-o zX6ViQY)MWFRESX-COz5};v7oNOI-H#G(i3pUZ)WDJBxcT=CKTwn<`G=9NrNe_k$c+ zrBsbWlWs;70YWC5{2hURw5wBpAET^Xz$m~&5vvpbpuW8Z<+r|0l__uMD1R=E6j^PuVUBB}q@Axx#h6px@+(UNVJOzE-ul{u z;*-oBXajlQca3om+~0@_dlWwxpSB*-xiuq%9;fTV<#KJ7g)b^+md@fMS)VRQ{S3<+aT>E>Z4! z{!Y)QH2Mu4mP&Mrv@V$(G<=fGfBQo>-(cz|c}1}4<4I+S^M-014a zY5nU0iK2d1DE-v6ms0${*~osW5#P;NCgk&{!2R&Ly0$JqDGEXB6UfCew873QNXL2arda zzEC`Gws=0!FtUTjL!X(y3M?-mHGQFfzY%)&1hV9N6&j&_?NxDD&oDk;bd4QjJ7V*! zFR!y)Fmv5gADjE7`{>CK@ttTPt+Xm;a zM9J!^mTvp^$nxXMpiPEg{vKv*h-DkAb#-ib{_Qf@?FZ* zO@kfVueK_4FJ&BHuNfF4OZ=*%14@%rf2RdhIq^BSFZeOX1Gqlnm!;o#Ka>1Io`)P0 z`4$}po?|Gfd>IsfP}JF>MQaaSOXF;#!1BLaN@TT74s8Za7#P)-z4r3h0<7RonPbd5 zMd6QGguzA44ros#Y>OW*Q6-h7!nUZdDj!?m)IOPOP0KC z?}bnHC9W9fRhLkH<@Xz@!JIz{*N=+>?XKfW&)BcFe?X-}5_Xt#h9LTsQ-3?R(#QU;EnE-uIT(bn)yd6pRd> zNBdHP-zF4Q6;Vd168_k;WiKA5>I2zDI@l-f{<6mWH?u)sFfld9AG^h7FrYt=1oqPO zulMhf29+p8#-)TpR!a_@H*|c}?3xf`Vg*QQ*eZ-)z9bISN@T|5-()VNhBAhmLPg)> z0Dn0lN}Pd~eUYe^7xNBNDN+)=$sF!w!oyR}`BrW~R@x=+-M4zsu%wvE0Sg?huUGJn zFB7By8NDmM=*IBjcAxKZh2Ibv#Mh%ANYvh_^jxT~(v)W&nMk=2_^fvLd=PAxQ(d_W z-yTkq5X-hkOu1|L3{cWDoxpEwa|L&tg%prfgy<}SWAU55=RD(_Uw1QYCe`*^{zPnz zrmfgKgxqb-F&3p!+)K^HO0<+|yiYZ>q1vVGzSXo~0NL$X_vb84_^~Y(cjTRFC|^Gg z&zRzxN1i9O+{gYmRyBfSpL+HeLAw>WVo(d=lNk@=`m}=}A}5P41{0~+T}{?ZPPjOBK*KxDhpwkM1Q4$0M*zC7&65%BT2#u#b^zZ5Eq2EMIWj+hu zGX%ZSUjC4{B?UX&lC7*T+?+4mA`_yNIsrZ7RJr00Why9FY;lAzXe+^VEHo%sKHurX zYiDi2GvMJbJrC3{{mD-@UK~@j*%Nq|R#*om@TsPlT+pg>7Fe2DYTaseQdmB12D#vO z^sx{sNM}5!-hSd#&U4;OcNwr$pGT?N)SqwrP9wmyv{KNV5~&G)B|Yoj|FNrR_53Y< zZtY9~!cP^2)}$b;NIk2cHh!9OJcsKYM+GULk+yB2K0TI&KF5Ti5AzRgr%fQUiMv(> zl(TM9{|1(8zr^s(9UP>irbU+@&qGsXqt|ZYwmb}~kptFXdWU_PDb~JvQx7?IVF^^k ze(5i;w+Mk=fm~s`pH->iNH8%mLgfH}*a_{gT2nNdbse9zdFO??jRKd2@)GeZCCV@u zp9pLu#FG^yKQ8Nx0?d;d9D66H+jX}4Yrdc2 z0m5xFjjJ2A)g~V4F6Uz$s{%ky8tG>cUpbw~+JPXmT+cVi*)|P_ExJnTx!acT!H{U7 z%U?dVMc#R{5DOAC)%d1$v!!lBX~MRb-w%V@e8Z}Dy`DPA@vKWtVg)aVAkv7CJ+oyo z?YJR*J?V6W>M3_LT7rzZ@8uU#x!$rtc$-Pda8sm+UHl@9Y-~st^bPM{B&^l|;t5@? z({IPTa4KBis!hGtLHP_;aNIG6D&(F(gi}_4Z!oZsv|lyj{J}>I&r%!eIMizjYRgXm z)(Mpb>?-xKKWj~SAw%ryEM6XOrpQ3+vHsD?I|e9%Z#Bg3PR%oy6}j-# z%~M#5&Q+$5txoML+Tw_%S5H0wHK3+n62nECwy$+Z3i;1-A zRSKS~qO?@SOrP(TtKJahnAO@e(|`*5uWnAYc=L+@%;QS}G6uLMZSi0l0olh^&fYFT z^;^}J_d`C0%9d_eaGqx`n-um*H_Zv49Z=-&B6WBsltSQ7;CQez*?qR8cKxc<<1VM6 zTa&lMm>qIMtWuuE3R4IYmfD56~#>hH9;C)+R|S@b4ky#?Wfo)46bprJ}4Xx&JZJFyHH@zILTUS z-mjWE2i`=rK>zB{dp($#sJiJQsi$k)23v|wbfLRJo& zm%rfPDUzf28;LZeulAv;?hu*H|FFaV?ZB@QH%_6)L_3vRE$0R?L9_{mU-TJ0>{gZ* zX@k-46_2&oHqx8Zv$ezYfe1McT#_m%&|lesCpn-3SQBNz z9}k#&4DPNG#0zvTRn&Q!@*W!HR&HupT;YRH@*rY~pPiujj6ujp4GjE_W$nL8|Op zW=`lKOrgzt1?>FR2Zr&!YANd^j<-_*>Vk@Vc?Y5eL3)04;b(#1V;bX_kNS>Hoirsb zqjg8e>slvtbxrNnbaVJQ{!L{}Q|f`a`ztGh7Ti6mWhlalRr51&xAKmkjMtarnX^|N z+Sk9dokl$1`x9oK?w7>BtXw9yvUC-|sISDVba`}&9oVV$!VC!b#*b&dxkyi#$_3x2 z&QvHEDL)OM|Pr{W%d-TTf!nO_-9Buj^Muk^;Z2~d6T~&p#$CQ zoGSGU&pWK!{Ww2ve6X8xXul8Px{Abma!2dhE)pPdjHO@EJd05{g;{4vjp|Rzt4_1Q z5A*UTqYQ8!&7y3yh}1IAsYZbZsLV~PhfoKSpPZ9imNm8UYRO|06y~KE#y#H?3FLs~ zrxD;tPRR0S9T@TL_19b+b`dudlGiug2F5JMTqNhs<%cgqQ1*|EE>)b+9|F#5kNII( zF|AIGF&U58g;7G}N>lm#cFOZ7-SXi4d9xg~EFJfERYK{mOBZINECrPrwUq@@=;}{X zLx^XHWy^Tw*kUfJgwOBi=!F=@w>SFux5-oXGVlM3G!VD#nmE0iGj`?#)uT zi4_5v#&s`ub09)mPbtIuWs`wl%HfkkY5_WUTUG_{H_GUBbo(w3O2>T1jxr8OU7jN+ zlIN$fWz9OfLi61-24f!{oeQTO#3_;>X#0oh}J;%CD+H8W0qvl6M%!9E+Iz!s|Dm}2_t1VI`O zwAVsEr{La~ZIyXu%LR6Q3d-Z2>LH$uv6)HSvtPI8mF7R!4`&FIelxf!dbZyH6P^{M zGx61ZHD2yFb{65A?%ttCV0zrphp0@i&jFV`ynKBMY*kk44jJvYRAy`y(K6p^&I6W@ z_Xn;t*LDYJRjSR8tkuWMI_C&?j{=58$pw@iz*Eb;W_yzJR_dgM5yAEA^5-AC zhcyS?dYLw{dMt9tUJo5I6`h+_6|O-Q6-0NlT}Qpm9kAy&l>7Fr;!>tpZ^5(_z^s-v z#FnF<*a`e=6A^6rW!-QpDG@(7r(5zCB8f}vGvB}A3<${h{N3NRk-)qIMYxaPzY^IS zUZdDq@~k4&F_I@B_$$(ay*RF;StYanW6=%8k2StQEMebBPmsJ>~MUclN{x&NW_ zomTuBt<}fLe149e!P17X`i4x(u(oWs%)Xu!!x&VDtlz0b3Z^L6jL?gUHR#Vci~Sy; zYj?}$d+q*#fk0+)g5r3jIy#Tr>Qmozd@RyEg6%uWfwZJL)x@ z8y2V#-O0FMHdF7GebzD{5`yk6$xWU0-Q606Ktlhe+2Yx)gM|H{@2El}wPX|7Cr`qiNOki2j% z9aj>1T3>yx*vkrDzi(-`c-t4q%()EmTDk*&7(fzyBtKiXYY#d36`f1T58_ba(GN1r z<8i{1u;-ht{7egd1-|cAElN(lSZG>0+Dc$FPjT8Ni#pA|Tr^$#n$MR#DNf=y-AxN) z4V`ajW)U&;g7A_F&e5?fsjXP(Zf>EDppmh~EK;PA?;5 zgx76S^-Np+n>p7T{>B9R&RdYE;wUGc`6ofhjCQ#o--J_3mPYT2Z@`cR83sU;2i}Ng z`K)A={MIGr7hX~F@W;C#U8rrtO03-sfRk(<7dqLwj|z0MaXwZF&=z$B^s(+oiR45^ZbJPa?|ULl;)Mxg3kc#5?rUDlm?j zH^v9l_q$md#q!m|2Gb}YC%^<7sGOB+aN{sc`FZU+GaPs15=i>kJ1$MRO;J{!pcKfesW3b4QgutYLO{O zcZzgE1a`3H2;I!jH!%p5hcNVa&xRXL>_FrzkB%m~<%+f58D2i66mRh z4js*Ftj9lSRPZj7Yij7B?a4YL%4Fc#%DyDr`7naSS~$Zxi#mReo!w3*ET3=FPUfPz zpNU&WDAHm=L9QQIl`&SKD1P+|dnE2O@5=q>x~4X)K3(X9opOwV&^s0R8rY8eFZhji z8i!;i&+y|2<5yi2w+Q#0(H64(^RCxY4dBKBVe3UH5Bxdyc6J#Si{a8&R&p!i-V}!= zNzut~TEaWdB1BwzHLq9-mc8PsZ7QX=x;gK52WyZk(9bbbTIsk?o9NDER1aSo!R=*F z`QhQJpa>0(G@p%*d<5lt48>);WW7p8bHXk%;uEWh5AoCEn|#luR_!xm%cEKq%YBtl z5>^h1l%c0NXcc92BYuKQ^UjE`5~3U+Z3^zAXy3;5!n_5(CDkqPP1Q<6f!`d z_TnZS>(`pIpIzlXAZLj}Lq%sfI?C`_E5I8L--fePsbia*e)TA#nPo$TB)=t z%fw85*{(mE&#G%-LmZ6B+G8}I48$7Hr1$ZE@$jj~A973Xef(}4e1)FGRW+5`;%BYB z8kz#96qw?NI~wfJ4&y_#W}r1g^b7*qh(9Zg_{zOd{eCsC7{aibvniqLBp-{ z&&SmPu6I80US6QL+)kyEl~yn1@?ujXf>iB}z2>DQReQ)rt|Zv|@ZQ60%NS?T2-&e$oDl!@tW z`6RdqTa^Z>W;ZkPcLg%L1rfKUk&W5&=yxx0+v#ObpoP%kBd?D1hTT%$uX;`8tc6wE zlFs&4?Yw)l6Q5sVFY&2!p6cigJTj|-@pfvYVAjmYHtXWLBYuLY2I{nU&gEXDc#*&8 z=}F4C=+~x;jeoOk__~zcf6sJqv;x96b#M(U6T?OE|-L ziM)H9o$w&pVVGLB(j2&X1k`ismN9PZ!G+;Eo8B3-t9;r4Hu$GLmyE|BF{aC<>ptsR zQ8jKLoSO8@-5XFO|2HlhWnk{MTwQMOHtY@T)^8R61cI!>i}!%>2TxxDm(e9=0Gsu(0P4w#TNHlzi&j9^L8UVmjxu{Br#8#5uLwM!^$+mdpo z%G;XO3KpdpZ8OC7{afi^*N=hM2n$sZb%YM==RgSj_3E3QZ>o>jDCHb@o)MK2-Q#nW z&CstL=B}PC1jG0>UisCSx*#4$!eStAN=B)_l_$*6gu7WhX9>;Cp>nj(u9nuviQpfr zm=auC#dr9zfLZ)2ifi;;6*?}kDrXapp0KXI$dL42yBr1IHK~psnLk{#H$Es^u1Ke0 zPK5PN>P!~H9?FgXDB@X)!J}G*>9V@cy7}-aKEDbcb6FwmLyL*EBqIJH<<*1+EVqDKz(d}$C z(IU~n%L)nXuMj)$GrTM(NHiVbW@=rZ;hyt(`FGYNp=+I&C7EJR_`8kY723LU_XG@s zL}*RKha%N<4v3*#(WqZkztdwQ03-yseC|=*_3zn^vU!I(NU=t99%c2AE~>`G5rS2+ zyJcqCmie=kcpfvpQTTq!Ti#uD7)bYsaj-D8>0v0DDI{Js-GdM8RdSPL!?|tJIxDpX zmW`^5q|nUBu}x@fiVkC3mS?2)RcYk3tUqv$!1OKjdv*1(t68ExVx%#hCTX2hP1g(Y zNv(cQRDTF3!jBPMdOQdSVKsD4$AR~wDAl;+9!-=`+i}lVd$o9$J+AMrux^tTRL7W$ z^gZeDh5G_h+3p;~TQ0Fb@n0MtBh|jy)2)M0#!QCF(~KTo8px*qmvf36IlvZtIp{(m%c(hT(7E zN1yJCf=}|dz-7Mv4~;Q9t3Q@YRf)VtFmy$>_&`h25^Xl`<6 zy#TXd7pkNVM5tANR6f9!BW6)e-D-+qK3rZYm`nv>_$rvDC#kKj&~-x|f*x@sib5xi zG4IR0k(}LbGv|-t^-hZzhr7_7@)Zem><`GR?KAGS$tKmqCo!+{X#f zQpm9>%sp)eHc0d5?RhFV^mNZ|?_4B1ZEnlyc;VS>>429wh9{3hiJp!w67Gw3G^Ov# z=|mQ++Rh^DQbpFje*6RBCN*_!=^3hFN zo9(tyhs3o83O)@Zft8hq_j_9`HL-7E$Hl%^z9m6ZYKF- z7P5Wt@kLyn_T#sa!pbOw-a0d{VIIvz5hm?-TJ!j^okaWYiOPG_end@*$$^RrmeJ%)~wm4GKTM$udECXTAVxrLqRk+Yrc=drq?He0<_ zdHJiAFfM&aAwvNTA|bn16VooAU0%J*`MW=L7pJgpEoD;dqFv1Fp`ODFWWdA5_bcr$ z<;niK1%$Ai%9nr>^X6=FvDPOX2qy1m}%tnbA-Hz z1X$xTI|SkSV5Y3CvS6g!61WM>xXTK;~M9~I19#ga}rqlba#H)DGXNp zeA*jX(Gu%<5=8}arQ_v!j34Gxc>ggGe0c;JBcZ!ax z(gUXssMWhT&5z)(x)?^AezNr|s1nnwcZ(#WW6A0_C7mBpu2Yx_ z^Flt`8Sc!Vn>DqWY^WX~pH@{VX7Ji_CG{Ab^g3~yJDOt~4F1!o@aOYijuaF1KA)Z+$P2Wk zQ~%4|a#{VGkW&SYkC)2L`|=-SY*Enx*2E)ZQu~J%Up)qJNDZfHZQj4n0>eMF>c^ml z@z=sKHvgxi&i?9HyT9?@9nkBzxF@IVVeeawFw;s8`BfQDM?mE3T}6-QIlj`_@5MhL zuF%g3A$G9CZpV%jJ#)}y%xctIW#Yh=VsBgH8RU$l^=U5|EjDEA2G-rIV5#TsVD^i| zyB;-aGDVt(dgx34#Opz9I8VnQ8`2{ptJB_9Uulk?#VNScNPreP+PP%8V&>@Y_w2o? zG~GMQ>YnUIZd543Cc3NCJ^hbW_+$%Mm8LF|kHsF}JboBl_mS^2TxRIUzN6I}K5lNu zLV1W~=)G9+SO*8r6hUOp4-m>kFIxFn0ldV*~W8ktGKRM!6zfV>qqzLEb4nBqn@*!%pj37cpBfBnK6{+&SomD8pE zc&@XaX8g?)4Bz6@L|i^$Vt_@XR|FdW(Q|XEch*-jW}#ehs7ti~$yqs~GfAA}dihh~ zWID)eLfiAxr1icmCB4Ta2RNo6=1oeCV%qzpXQu%cac7snY{vKDEL+0_UtOKmVS03B z&k=N*H^OCmg~YL-fg_J&H*4e7^!NOsoX_3pnh_jBpIW=uk5KrKIk&*$CfIaV85TUF zX9n?XQBr6P;V`uUddZxFF*L&@)=`qb|K8`amLOps*P*Mek3O+ zr+>>4hG-84(p^VYm0?Bthl$2l^&qK-5#tIf)PER>uCVvS_>qEZN!+0+4u54OGAxW# zn{qj~UX~m)0Hd7=d{hV%#PAvDzxnm=fJ^fPiFrzHwQYYBctQ5RHug6rs+EjAt2v`4Dq}%eJP`LSNG=N;C0hnu+9qbIBdrGNZ5Al`^ZaC1(Y5<8yq1w+ZYJHTs{8= z4q_FHHvkoFHVY}WU^UBZj<o(uhM$JfV#*+o#KsgQaGhtZK{gPsG<^*;Lg zk7YGRJ~)6Yp6-f7|DkLBX^CnzozLoKRRjor49)}UTlxB;&nW4^!C=8_VTdEEw-iQzSb;2O<@{2Nt60q$lP>^ym z$4|`8iYUy_UuL?6tRx|#m-tS^)^{QNtv&T(wr~NgvtO{tl6BXYvpG<)L?yWb)<{?z zgg%_1%6CcG^nRDI!gR5WE;YOLp4Pce-XtxqLhA|CaQ^a!A7teGu{U|vD-lK{XBy0H zgt|}(L@O$k#;P-y>FQ?~Buf9Rf3XL0&j>#&#b2%rV5JZoAD|>Bb=T$vw z*G2WLM64+Gt3Gd%%c5Uxn`X(#Do3FnovUT-#WlY|3BXY>)?i{*V8NqKmQ;l%5N+T% z9po#pl8+=|xBShCi6@5;aGTgqcQt#$@kc69_Pi}(WEWOniz_gXP#=1hhs+vG$B|x`b>9x)5G9QBe8T$Kc?}X~Ho?4B| zsGW`pcbp%WtnSgpEQ*ZUQjfrPgG_mmEhDXaDq6CGQ3H&ND(wV>Px=yg3A*PbAEi4E z$~5T0J7&!W?Cfx@-D4Jk0ggkRA~$ipJcAOhNL0i8PRmv*9VApw?W&X0@T7jcX_E1; z%g{Q5ljgA$Jc-)U+L>=$r{bdj%lKsW7~NFfHpr?HFvt1aNi+W(O&1OD%1*?0CStn3 z4i!KEjn{Xfk&x%+@1KRy_)lh-%S#N1_3?^`fQN8EX$~rz5|kM9@~wjds7IodW2$nK z+68lJC>MYMY!@Qok&ICUdHl$6N9tD^NkdMxe`x;MHTwp2&r$ zgk3yMZqd&Id0RTb3`eF@iIj~7v$EF9BU1nm>ROg<6;90|eY3bbsIsdk)=Q;?k#Eew z5^#SfhqmP$sD_Bj=t)jX(MmhaFTsxEK6mN@v3Y5b+v$Mj)D@vB=t{`y2&8IOj5*c9 z7G4!Cxs&`jToYcM^viW>hxINv z1lr8NRjYh$mi(WnmM=+@VSbphd@7rd3p5LC}b95qo!n)O0nQ$B8sJd?Z?H8Wb|_tzm@G09-BJ&Z_!MXguxfgydh zThIyPpl@}Pli#*`w;AqGkY6tO3N;+{M#fqc@@?W0>-ptDF}2g_@6_5_ORQ~MHCjI2 za*0@u@IU=?za%h(cPiD~j)!C{ax%O&$)v?lv`2#O$0teHe|-iBfS(nB+VOK=?Bsti zz7Qtq=9_F%e-cjg>L%-DJeQ`u%2zTH3}2-o;6+a@_AiQ629V7YAvpa*#~od`j(s#r zaF#f0U3M=n;f>Px5?(ZFf%-QwRfw6!{9fGXD~=5mmmMHj3%@bo_=%U*hSg6acUGyv z+|tRXu^`Wdpx{VfQR#evcqVQ3>OfL&-lDUE?@EGt^6R~u-F-X72E^)L_Ly&b8WH81 z+H}7uz1-Majit{1P+MMLb}{IKwbM!eWsl>k&~7HwrBl`^)fRvF5*Yc%Rklm-3SaiU zy7`S*R_5hg9&@p!)RH#+oE?Q~!3rSlEeZi)-f3GZcyfrw|Blu zp}EJrUHjmF?E)~*G@J}<8CBBzf?Tx%AsA`*J@pZ6u-e2XnKN*h+o{J_l}K21)P5wA zH2)O5U1pr?;8`~28+|;cd5^F_P_k)s)NXxeh}v|<-nM3R!jCo8quJ(?>5-Wdrcf!? z@<`IuPtgwKMCfP5a2LJP`$c*I9qkuWa*{uZzy7yNB8%O4vC5^aqyERGTRjLTytaxe zB=8OrPv|nG0id5*o!Rk^8nl!Q^MW7t9m~14$E0-a@$XY9t;9^jWJWwyV@oh%eBM`y z`KxXvfZ&(&*<{T_v$aL#O$(-zSYL6S&$nbQ`h!Xmo0aPWD}OqDCOt{#;fksPYO+;8 z{bb^aG-1#xS4t33_ZLw?VOueXOD1TMU7>pN}FKS2@uqc5B$_sVThiom3fYMJ6i)!3!>Diw;iJ7|zwW=5sHMD|-t zZb`o?$ouTWn4erjzZx&qbQ{6x*K85xP5Z?l^uDNcQ1AgiB@RA*8K0*a%~z6Upq@2V*DeO=%6If%LaMO%fik}SuaB= zQgG53{DX(jhH`Spm4DmW{qs00mAWduOk|eoI`QwC)(c1iWgse{bp>&V5T5GT2W33n zqGwtulE!K>F_KHm??jF1rnm!pr4et>P`vfj9>1`Ah)MdM0z1#qIX0D zmD&T?h81JyKD##-f$nX&)3{P9_2`@&xJxs5`dSDXi^&ych7tWN0nwsg7dyOGKUE@C zXKiutY$Xa}YHGU1G{$P82Zy1NJ^0^d@%@p(HE%b)d7+)PLoOn=frfK-7JiunuUCPG z%9>vj7n5y5C6a6a3i2gL8qb(D`!Aj^qqQOfQP|rpe&bJcF>1y{&ih^2WQOyl?D2+i z5dy?Pg5^`~yMNB;z4$6n?5eI-hu!{NVTeO8mqJI*T?}9GgXo$)xw(E+(LBM#5M5~- zOiD0d0)fzl=!UF@IG3_>Ykj4c&%Z#oj>OsxP6C5J8W0?Qu|ZaW;dN2n1K zpO05eDHY*PtgT=t(pt?{`%Lpi;`AWKUGc~L@&?qhPCgD}$C0ys{*a~g(&Z=1t!L}( zEMnt(F_+q2YiSnj-K~n1Ve1O6m4SP~k75oL9{qIKHr}W3i}6+4{5WpK0(3aX-#uDq zLF{{bRe3;`p2hBZ7gkzch1%q`oToQdNpYm2bmPfoLwBDhv(fh6hoC|X*bK+t+1nRQ z6oz<^+RPOkbvgSb{!IEJa1dCaKUc zy-(%ET3BDq4A$ou^%od*9^wQs&Qc`Y_U9oan`+-MzZ+5&7x2Ekl?ktSbnD!r3YF3m z;>9S#V7K$Gj%3fTn&RcD(=VeAeR1)ZgFA4A=Qq48&yZ)s?Ma51XP3XhmoGt znmQF@&*-sT$sDXIlONqSq@JQ2<8MN`{Jri|4?5}{j{=nrik`11R&@Hj$|P zto-uA0 zT8hjfo6Cl7-%|C<${ZWZ9nChRZIO{3UfB%zn#GTnWqYpWWonw$TWnJ#pe;GQ7AJ9B zGlLsC(!FQp$1z*^i`bO#W`+HM{g+ps^%>P7b2~0AFt6kF?g`PT)6}*!AAw(AvfS-1 z+Gk;0A4vX7;s8_(mG^ep>BH5KKVJO>ig{|I4hv6iFQ4cW6gRz=0{`jw0MJJ-{npEC zcfWzjsvti26dwH@b(oL2P=z{Uiy7n^F$=EIb7=Q~dGb87r>pS4;TxT18p)5S6HH@{!TNoid6+LnkOgt1% z?d_VN2+2{RK6)G%k87T zsU`wm%IG}tVtq?eZ7|aYq9k{PysED`PI~ehPm*Rr`VDXG_}oOx_{6ufw-Y*78&xLK zGPmzt1`30eHI4Gv3ICH5pQvL@+qa-!ubcAx@#@#+7<~yG1&4=kysueGoO}6yYzGMHh9v+;p2N1FJkr(EZ>*B~h2dpUgc< zch~A*lsYf6()5(4IT9o2_K0_Q&pP-ZYwpd*)0CM7quTm?&q}mp(--2(gKN$0WIL#l z%$Y4Ra5ZM@mW1|XRce-qxWv|Uohf{%p&iGN1*&syuzK_OQVzUqV0AoR;Z=laMiL30 zHQCLM)CIWC4_j=S$y_ZOX%gB$md{XZww-U}Si?C-Zz@6)3#bWH?&3m_#zpdzw6i=(6rA{f1m)p z8VAXBYl6hH8268>(mavBrYz&!VzsZ06b4K&W2tAw;)lBB_2R@y9T<_bU}&+XY$;F- zE$U1kWm(60gf}HrCkq*}gI&0C7&p&@i_fPS>-iD^;+a((K;w69R67$kpry;e_M7yJ zg^vlcH$qgiKd($1bFsGb%Y*uSB)s&aouzCqX#hPorz&VeU@S;$)Ke}c=C4VN>sf^O*DSM#K5X>)~5P|?L^TGG5BM0e)Gxb9?eBU*!x&(F;)aiQ18Pu zM==lMb`O2qFP;|$Ck|FKe)*cJzU;55ftTVqbG)-lYCHzz&p!8uQPF{$MoMN~(o$q^ z`lASF&yMEOa>&2>ZPVeDEl9Ux{x(deg3*Wj7tthtcgqN{F}6xteAQXlUR}rBx#tOg zw=`+~*O+_5e=ISogv>~mML%7<=V5-LlXVwZMU-X(MI@oEW_|QR%-5}^w>*;6&j#n# z>8G(3O!e#9QJfaglj4>crD6FuJwp=Y5x+W&QTF{&b96c6h7syBy$ie?fD2P-H+~>* zZnv8!kiiLdfE-AgD93UC+)+P7kLL~NP#a_jX%sdmj5VYl$E#AROkhunP4NeB(n?it zq*fJ*CMTU59W^Z|dc_wEINES}siTVH)(r7^p6$lcdB1ZO88Kz2MAw($#qO}c z6ldQU1C#bju|d2q3BaMT3F|hz|E*c*~v+gB9`H2R~zOFRhoziL*~;mr`KI1p|UO^Qmw`?LM<2B-dTjc1`9w%pgIm ziB|?zYqiV|*&AMYMUTimrKcAP*@rv`i`2lgJCls{Y3R%friEJ%vr;eH zwWX{H`S#7E8e>^1!;twUl0iVt3;nyxb`+Z+N8XZC4X zvE9Yh9YT+AcSTg`>`2MyPPgA;L)4^;lCq86~pDNlvWE9q;~ z9y$~)I=6tG$WgjC-ch%$)Yp0IZ7bCR7E#tceyPD^*_8b#gL&C0?}|(^1c!ODIpgx? z_;*!_L)$@BGq#K-zpao<{etDZ@ts3WCv#WQe))e`@gJ7{Km7Aa7pC-?Cw22{aG3q} z;V0kBgyV)oj)Jmp7Vz~<`TFWQPHW}+&n=5TJhYrC;Vp?J_)Ymez1BM2!uc#nq%Exb z#=9a$A??+}7^ZI@i{>$dADKRT?L0o1)|>5vu_VI1h#e=C@C`|OyK#)Y47&YxpM@;- zlzH}BGXF8onIKMC?;$pzTMp5@5y3s!xii9NSqPu-gDpf0SvkKZBRRRso_pGCP%yej z=0pKBjZl41rI&^EGub!h=n>RWqPAmYz%KmAQki5Fv;;D;TYvuTidtj7Um6-E-kbmH zYv_3u;QtJ=Vy=5+SvFmt|m?WY= zPK_bD(1qm}r*e_Dt7)z`@qDZ@w2MkG@&K261Sd%IH}Q540H*`u0mgWkV`F5DF(Z(| z86psk^r1wJS>VQ=D@$bm*{EjkKKEqF?z2)8u;{_-9v4Kq?sh$gSogQ+4%2I~^oN!R zih?@dgyVCV7@&XE-BjG7xn0+QA#3XT+)m~v&@n*b-#4sD_i!ya{s6Ov3N7H}J@Kz$ zJ4bpIiM5QMMa|5Ybb!~mMGZ!Bno+45$5h%@2Tgtr#~yi+DiB3gygYltzL7Ol0?;4{ z)!0qJgNlMMSf*W+RlRlgPUzTBu7&yYWDIXKU9GkG|0MDM*1Uf@ivK%vPGNQ}3Q{Y* zu(9Wl&th~qf*CY#jh<*lW>itj9T-yy$1#^%S0!x8S5hSacRVf#h=hz&VRyMY zI&EE7PX*3siD*JzIuU(5r+-A(+i3sJe#A9kK4e^HV3kJFGUEV<_d5PH6qzQF0Uc7UkMZbOcPWOkr zhF#QU1E7xSy2z&^VOX$VJPjDhL3y@FyqzAmL+9xq!LJnimY*Gbhbb;$kZ`1XoRYu= zVZvEfjqj=%w^gcz)w$P4cD*LrMDsd+wVR(%f7+6ZeuNoT_&VUL@UYkB_IEfYa%(L$ z!g{28k$PL_MM)Cj^E-bhkpG?8{#P&j!i%Xh$P<3yESeUeouX@(_VCdA_ElgEhDXep zMm9r}vB{KDETzCtoWLiEkUHt>um7Y0#{cdykgbUufma17ip+>W{7@WxDK#m-X79DxbyP!wqSUInf|Ns{3%OM(E+^gq!bf;}k=iyfVWj1IvDLt(OqQ}(W5Z~% z$$=~fD*5iFaldX`nNUYW5`FRWh7kkawDHYW_raj@-ApI@ylOt*YtjWAK6FyN!#13p;J_qV4XJ}|8f>02Y67eC=^0lz} zdQ();Y0rfQLG$Yf&5;&hRjGex4~Pli&M&v}H~oM|V+^%Nt19gN-)v~)g6(j3X=B@5 zivGZu-;Z^13RJ};W>Ig4GtL*rD54(APTeIbk=8zlFKltW#Ea(F3jj6ZQSh#bi-U%W zvJHI@GL>C?JWQyxoJ9K+ac&M75cTD&$2gK;gKw2~mju940>YlV-UmNUvV3A#9*;sx zR9xw1*P%*<1^EJXTUM%&C;YTzcO9rR7yt<@9rxFm_shLu*AyqQR)n zbm?xoPp&WZ^H)N5=gSZ2I}aWK@7RBenz#jY)gQpua2*NPY>1!i8O`yLYIJQJw@aU1 zu9ec^{ZkwKzq-#~J^z2Wn!hT6wn~TwdmEj=ZAU^H=U@X&JGC7Ad#tIaH^@Qo@N%1i z1wKxd_ciZ0YT_(K*lz>tw{Wi4Zt-S+HD*-Jr-S|9DW@8i5OT%8w0AuY@y5LSx_i@!#7zk!b98 zHTe2PqhJ(|`AK9Y5Ra8~L}53HDE^;f6RWJzO!KQ*-42!@l13F7iykG>#)6NF+h3pA zYPPxFca}?iS1uALxcGeCiHCI%Hb_j@PWvR}dX%G6UwN4ZGT+nI7n%m2X zDp;PNL5@v39}IWzy-ty13M5t`Kq5|RYiBg^B=RE(qy1RA#|EaPb7B6e883+W>~uRS zXlSRB3Ox%%wJ9Xlx&qX2mygUl3uk6zV%iJYK1Lb43x2H4-vIVPbW%+B?#Mnv-j(>& zM_!413RS{@l(?8$G+bGFRlBU88JID2KWsU7U$(-O0!vlD)LZtTqq(xw@?bOiTS5BI z%Ju&cy<205$hx`I3{@BOd}eTFIBu9fXaC`OH#6gXZLmq$t*^Zf)R!+p0U_3JWQB*3 z>F`obMx2*4y%mLe-%>$DTCXo46r#qg8D4I?keO_`I&T?F+AnR#>od4m zdN^2W`rE$#i$n`xkJ{(S|9C*x=m0xxIWbRoEmld$>g>8>KC~3QCQo2VP^%!;cX|)u zrZcV+P7<+;FGk-24sL5uv#!gO+%jhQ&Fli*@tE-3eI7no;H-=lI(j0CNfe4hOlT6_ z4~Exf+ZQ3Hdr%$S@jh<4_o)1x=sWi@xu*@yGo*TH=GT7>`_@Rjo!Xkzm%oS3C_V~y z$hWLJQ|CiJF9M&qpaY&hT{F=YD%72IMk@jBc{&!Jddyu7OT+XZOm2nan_NR%rixW) zor(vuYW*2&gBy`NsF{Hl1B4>Ouy44qBXY)}&=M}jECxbQ^I<7~vJ!k|z5{Y6tt+vb zm@QgZXx2ldm`$9>El2vUIBT7~hvGBqXjEi8WDfUKrlD-gG2kze*#8Vz+BO)#9I}EN za?)b3b{ilQ_}f4Krv?65`Tfb_;!|_!zf*z#C(x4t!)#zCwoLCMR}8Rj`V|Ji$xPz} z5$nW4ny6hJ1G4b%vV@+}5Q>R~U4xemAXA!P;XQVySulH$zA_%=qbk{Kn?tAT>~d)# z(%{-;vl&XCAld?Hd;Wwvf9eb~`%R%lWt#J9dTkS zYp31rQEOOn5D|H^xw(nx<*2R)fpULj_$*P?MkSv%;EKX*JEjgBkyA2r+5{V6qNr-p zDxq<+`5Q~&a^l~{>i-6_>3>8(zF#qhd$Mio(d@Bw)$#9PGbb`>4V>XLXYjA0JK!8> zqV_-T)m}eMvVTh~?9jo}BR3=~zv^AO>i1qxd#Q*Fa zes`2tag@*rmA|3W8e&6z)-F?bzef(voRpeetEdO>mi`!SZr( zDJvZ6ebk-5uGot6O?kP;(!uhgCGlOGNI7ew06us{%aX`kp~r0{^M0y8*XI`u?q+?$ z|KEGc|=p1UzksyW3ljhv52njN(~BUia5yl!I%h5sz} zcbk=w@&xF+{=TGf@}vp!2TOqu%wOl8=^7dnzCbAEtb&!tcVT&UKwc* zAEGb;#C6p|fo^?|OP#{aF0{OKm6Gzhd5Z_caZ^O^x*OU?@MAna5&0niJ6U%>;(Ok| zV-p0>$WL6E$)SKtCmhO&8I{=|;SY2J6_R`3#BKzXL5Tg&?J7j_h%KKjRDaRSBooBg z1}&M)<6h|>PL%f#U!f^W=KJ?F*hpmi>+d=%pt8ZW{V$r|UlTJx^-c(XBkd|1n1K|< z3_(pQAJ6buTbrTe3R_QF66>H@K-w#QuF)tpv&IJ0$jN(%4`4kYs zWZDGYg+U|p5n6}aL%98YU>f^Xh+J@33 z{Pf#`g#)DNPzOumdX5c1v?+YcAkQ66I-RDFKZ=aAE@m9q zh(+*DoWDyk7TT-xmsjO_`9rPRF2Nm!MHfUNQfO3nFJf`LWblKP3Hp(MSGx%~Q|i9q z)$by`mOgR1_c$I^?j^AY9$vc2Uh8+qz2Po_F7W&!^Hf!7OZWU_7bUOWLd6+ryTr5o zzLJ|&x{cUb=Ks9Nz?<($?bt_bw~{s!Z&&SaOjMfGNrbAJ$jO9zXyj^Y@xgtBc}r8)`hZBS8BMW$I-Ro&1V zT;PJ<43#vcZ{rccBE0Wun7Rv{8=3q4`(2Tc;dklNQho^PF#*kc@Rxg#gZ@(+*HI$r z0dQNIsMm?N3JB5X@wRn~7Ic1%4m0y{KJHy~oH$16+`T}2U`j??z*-oh`FYyUDH{r|>w|6FwjPCwk%UGX`a=A-HCf^_HkPt0Kle@y|qVU4j@ z4DJX>cT@X{baPjv3^H3nu7BpVM{Tj_m4;mJn8gGBGy|1il}RD9>7lkI5A^XAM|+H5 z%S%goXKp$NZ#O+~RlU1AT)bfVF&m-8hc;^Yi9?pH=v-DlUP@~;0-NwFel(nBv%(hP z`$-XMy66`jLvRS>f`*8-N<|rPP{LAGIXt~D|3D$S{$y=M>LkO75*0{Or-oSrjt|G( za~hoqVBCnuMMUyG+d0(f9SCJ?+*YA1s3Eac2Zhdhu9Q#A8Zsr)$3wf6F1L@!5%<(A z=kl}PiX6*5ANZ>SA()uQe8|r?imQzkAK1unFY68tW3sW0lG*oITmEU~!Zvq4 zhv{%hZ^iF>ED^ra@P-8Y@*0SuQ;5J!h6F=gULu<{O->^17v89`>YQ$l9O5e|D8R7B zghO|iq(S_WVu2Hed4KY_Qtx6>xVM*)o}4~+bnMl>b5~c?hNyE@oRKk?{Cw|^GdOVK z+gVi{E?>uZiEO^O@<_^tjm=((`mh!~6uK2y_6OB;$cXZ{I!9qdw9(t&Z5+~=3R&38 zxAz+`V!g_t5A%gpQ}@h49D0P4x7(U%*bxw1zc~U$fpG8*#tLZV>T;8OqKm=MINYbx z9L(@^-*;@HWH^J1*AzyBd=OSO&=}|t|HqeKE@p?=VF8tswPqsxha};*#OOj$<$;*m zrb^#NBAi$fw=q(p^2s5|(J>$$gv0wJe!CLDUoGtV^Y7^Wyb$vUm+h?>z914p9mZpI zAn>%N>ZH%bbQ}B)3Td!BLu6~DgExRQ&9Vd z>nH#M)xWOzNO+&r8d7f6Jaje(u4AcG41yhnZ>954mW~eZ&JJaCsR8LFn+SG^uu0dQ z^|tjNWv#HVK?O9qWf&BaOh1HKO??r}sGuAKBaBqzikjhme;Ddetf4zji zB8=@Z?05xURX8tOwl|POYRv>0XJsfH+-LDd)(h@yZvRw^hYuEpdXH|_n+w}y60LPz zf12ZgUH#P&DyQFsM`ldAyNJ(z-XWyaHe7<(J|-iALwaXlOaB^PefUPC;-GFDIca(~ zSH#NN35Fc5PGSv`hM3oA&~`Y5Q$hckS;YG72jlABCTrVEf!=L(Lp2m&ZgZb{|ML)v zG5cCpvD5j3Og3FGuLtUE!2RI#alQ>W5fwNqi^ufF&pU zzrWC(AVVSXuZU$fA2sz=l)~M6A7t@2$@@FsNRmpL`yqeJ%@V|zN0$?a{vk?MCoio8 z@zc!}6(x+Gj&A!aj3?3Vw_RRA7N;jFtK>rZIxFS+$h!nr@UJr|Za;~mLd2v`N}UC) zEg5Wue9>heqdKl)6Epp5$B)a6)U-zV1Geb2=(?$u4p9Z*2halSBr zGE!DbvO-~%Pc=BE7OuZ2^|N%IvDRo9UUW~bFIPyaX3i-h;~@ z3|KFBhN-je3AA--#~B6`#S%OhySa@>Rn)Gv_puz1ghnDkq8=h^{z+9k&@*42suwT( zokMHq;sXpgN0Z(8^oIyIemGX{=%-bSbrnI|dU#_B zD0yJkpowrTEIa#O7*Nzu*hr&8d~nDhFuqw>ZQ&obEiN1q#B0wt{R8NJ{um5|+%}8t z?%&x=&bL0NcHz1spVHmhi4)}eYNOJI0m7boOk_4m&+Ge^l;vVo8kq*~<4M(J}fL%%x`i2;NhPlYhjgNH} z?PPSRuqCK)W_)LTH*DVtHW@Av%8&{z9$4uK=<{|$&Atz0X>UezoNp^$SdJl9P$DtG zc)4RXCx~c#GY)^M%bG~p*!6iJ>d>;J(aTEdFGo*hZ+T};N@g^&Og>;eG#pti=gZgc zJ6{@V_`w=;lMtxE$!Dnbib;xTiP9zn4YR*TQk$ic`kJJ&&i{<_QX?3SS^(n|!Mo*MeUv~p+L^MR?8c!XE4jo6)kZ*M^fQS zhu(a|JrEz^Bh5qn2WU|rLWF@!JPX#@wO(k3r5!Q+XfPW9+V`>D`w^Z2_yGT_uao&< zP)H;t>UnFh7|IK0I&L)j#ZoJ9bp@e~raUH_Wqf%)#}yP({*UUdC=c0$niy;I6uDHS zLSJnvw=E`B-h2#lTo0k1KKTv4R7W3sk*b`fl4JY!CM^Tj`{AfwS**we2LeM19%5slW0z#BMgLkW-KTb9j8I5%R7-M%o6=P{=RW>9EyB3%{d9H64F^In zokpzeG=1!EVzn^OMtFYT&-(ry^rj~sq=e~T3;ACw`rp6t>%;8u0!62%Qc?Z&Jl4MV zg<}jaI>Os$h9p-+_xmX!yh)xB=>2)jVtvxp+uchk%iHtOQx?U|H7CrUZss$I2^vQt%-(z2~6aVQM^s8W*=iFIlgf z&hGVAw&Kmw;FR1hopn#0u(|lS_PO^ow9vbwPG}(997ioXXW~Q2u~@EigmzeJy-9n7 zGd&6J+ed3fc}|F1rk;`}=&NCCxV*~ucF3mkJ0^ipE;oD(wzvcC^%1qrvj$?8 zyRnKO_ihc>bOE$^sCi_9-|uh}Q|1>A2m-l0*T-ZP&tr`-r91{z)pZ+_sw1>cwyk~g z%fyVf$0zav@AHU%{FFn1CeQg_VY?4;9cV(2`sff0KdL8d{^}=L*U%%pJ86Zrr0+8* zb`AE=7VDvhQf|U05LiP+U%t&LrKvOS{Py5m3G~(;w3nG{eGhw}IRL?#0Kh&6c*^b& zdR{)vmH1N>JGWRWIXr@UK3jYh1;8RC9!~+^I7rk}@YcQzHBkiVj~o99 z1FS_#*7sv17tilUzS~GGIkvquL+)KY_`c_;D5$fgWu6v*THl)0?pp@6HE&_4qAU>D zhk5OWa?fxXR-%b7<$IE~YOGM1rs#0wS8BwehMJ&Eu%GB`H<6iu6;3DqziF^!hS1wX z$yk;6UqFB1LPd8-l(S#!DE7m7LXI4UjPO>zZP#sGn#juMWyq+VWbZN)JvZ-(zrV~z z6tB=46Q|#3+s2v1i@Y{;;j9pvU}0K>65ZB&{M)-#iRS?*R;J9at2Y@Hq%x1rE(CQE zmep<`j3;CnGXYQ;i-rDT00R!xAM@N9(HVbD$!HcF1849sx{w*jE@tInte#D?r{EMj zAA}*Z(;-Mg*OR41-#B~gR&o@do`bbcnzea}@i__=`dbnLMgFmGb&mMn_V8r!Kr={y zYP2^f%Qkn=UVeTJ@_{~W_oxECP4(QXb#q~)N+V*ZJ^MuXP9d8z?TtLoI-`_TkoHXH z7{nb64$(R6H!dmtfDF8Z4!J)ue{HRQ|41#w^|uD%O$Xv=O|K8KiHm;jBM5%@T^Rw1@;1T zHD{KYY&x8hT&B}(N>i=@%(k{_m=Oyyb84Og={(6hnLcgzUT44nA=ebA<+SUJ*2A*- z3bn`e;g=LHYt01lQ~vhb^{_#R^5}7#(J%N)ZH{1n7CZ@g7X>N$!mZmX8GR zEay77=5L9gNP1fWMbZ4I#9dEViRp7F2@Tmoeev>$$F)C@i9&Ds;$;s@^?g?TrEs8H zVnVoktvISholcGr?@6RyqAacu?!W@ZiHi)Djz&<2C+0>Aa{lJfdS@q6P0(Fs)WjUO zma_NxhJwT9tK(1$+!JmMcgvy)J<*x2zdCgo85GXB|iR6$Tmiv_2Hzwo%edyNG?;*YXGDNif*|;&0I*N1?2PM9irqf zoZG|PD;zJ6J3_+^_8a`YxISKrjrJQorYEg8*VlWyIZ<2$qQ7Ku}hw zxYqTito`{u+@=A4$_WPN(gr4*dEB`N=3k;GQLEje|%VZ;bPRPLoQCx`HMB| zr@wjDOLD*Zqc-!vzwZDEY&o5Y=}Jg=a6p6-dd{wI-$Mhnh)a;OZ;y^(2P8@ODtVKU zJfWnWZb}SL>Y?HLAXp(&zjx4n>`vL$?nEP-B13T4G~feC-yS?Z&6&Qr&e3}t!WohjNsfw*v-g5IWP)fq`16X`O5_!SRE{7pJI&2voAY-@&54cN zr1^_oq|IItAtv`JbMU6l8!Zt9KxisOUuE6BGQyv)T}Xzjt#x{ib8+%SYekiDj!8j) zK0~Q$q`12Yb~+R*Rl3bv+8xg!{Pgy>5~rvV)ZcDaDIUL_43CjCVB9(|Iz2SnWyNbI z&AtbJ`$rhlc^bnA-$_fAxaWY2H#qC@K<{Or*JiA+055xE>ZpF@cuxe5cvC!O9~^_{2#Zg;u=R^ZhM7>&bJ1udkS$?Rs4J46qM z)uT9G>KQLSK;BKO6OrQ(>-G@)=q35YHs7kx%Yz=f+xezvs#?8~v9^War$Y~8L8Ib- z5tiaY-yE+^wSLU|T4?_v%M`Mux5aUIc)zsU$IjS0+v#KLa26QOn?Ow9Q`4FZ*Tgnn zp>@B+q%hD%L&y~$jZm-xM8aqV{G5)zMvcfS-l$c2rjdRnqCUJ-H;a!7;(MdHSZ?V2 zdpFN^O(nL_&(1khb$@g#zhbB%mZ^uz1DmpowhNrRS)xW&SOwxcnV&X9MhU>F+s9~(f2m^2)=45nMKw{ z+`G^vNnMJI<6fHJI})6n zhUc)Ni;X<3(R!cU2dGmiKwj3k<45{D0XG$gISD*nPMV&s{>p$d zu#W154;2eUW>wR5Gf@(A+8z6qDyz6tX>@lXy##;rzVFWNX32${(6+^X>7%G*A(e1} zjs*LnMR941V=>iE*M6k9Y0Rmn!h};2YCl+ll*=lqFybQH19Vy-8_DT;Yd3l~UrMi* zfdO(WJz1zPfBhw&5GZy9NJugTH2w25j3v<4zhWp0sCjur@9XEqA*^mBRy`5vpY!(wJSU;eCe0VKYkO)APK@SVBrLH*Fx!vQ3cb5exD8)Mr&~8Bf+)e=-&D zR8#N{61Kci@2}07c8VM+Ef-xI`+OFAvU;4d)Uv=ZbX>PK2P;J82p!&hTitzJYE zGrioFUNn=5n6x}3b_Tuq2$h@4{L@<7tY-u|bFoQ`?La~smLMVrsR*Sd8815t zLVh5rZWxGDk^x;}t7)(C&VLrjtfmcn`O1%eqe7afjhzxRBb~B@`q1 zHG(x!#Rrhs=D}No1zyy**mC1Oia8S4)n^o4a(#&>soTE&iQ|_V0OfeDcE2JJ6gPZ> zK3B`0rw%)-E$xwojTc3ibmKb7ZtdWL&J)JTIdb&HnlrQZq7!PG$fzMKFN5R@lao^U1lfVlx z;y{SSIYJg)4F;^mtbOiLH$J&X$+rtsV0w{GNP*&aG zB-+zKJx3w}HcRi5N!$-oDksZYroY({ekt%{dnQ0@=O*=evj-UI1mBjAJS;a9|4 zW4$o#$YfX}CL!m!lkS3??)S)MInBUsw<>%wKwo}aJn+eMlMzHBc-jHQ8M#uRKx%8F z)9idTT?2%prT{0irX4lYcpdYT`Q55Cnm8Uc+$Zzp*gcMF6LI4$b0P+Ogm88o4=Z~E zLB%x|e+I0o`l<3IqeucTvM)#}Mu(Hxd+kUqFhTR+{i*)PH8h`}g*si0! z+`-5&nw`3Z*=96|zp`2(=%rQ*dENfdm}A@;e(J^y7=E^^E<7ZHj0+v4hBx0?pGN53 z4Hu;!tDo?yWKsAGFBVm?b<=nU*O?3YQi+~+AQOVdP)UFX|tJ+k+B1vr2Gh$&=r?NDx)CyCs5v#Dm> zXT7>Zv9|${@3q(EWs4bpAN#c#Oc8lXv^uS2+nPo7zS5ql0fKe#?)yk!`_qxFKtZPS z(R=_&STq^6Jiuzvh>bco&j*=ru2V+x?-0{{0#OJ}UxR~7n2dVDX`7+QDHtd4hne|T z=4-xea2IP+L4w)xtG`q#q1DeChP9X_#Lc+Jlyxn}=0pk3Z#oDr0x@j-aAy=z{A>l2 zWa+npkEddeb0&$&K_u=pmo2NDM-#HlS7qbb5r`#O7i)mNXtoQDPX%tsXXi*`p}3C z?o9m4))Nkg5AW1wjvV(yN~W8k!tncvo&`nDIe;3RMwpI%r!Yw}*3h?+MC0#P61@9u z&sV*TnUA8$Tuv7H1DOxtX%1z^YvPFOqxkF@L4V+xRGF0=Q_1!63U&G}>=XIj@B=lX z4+0;F`#TQlPTN0sVS@;YgQ11kQT~iPg|!QHb=phN*eZ2#d+ENUFG}3sX&@_Z)ZYyrBBut8XIM&hUb9{*smhRx`|g~RJwmQ z+xcDpe1EXx^bJr5+)$~&Q^qx=LQi`Nrf2Kj8eS{z2aJxL2-HacQC;D%F!qrduu!Fk zn&GltZrKN7p`EpC83AnH@MUrKNv6F?S|V^HfjYzW$KBnij}1Wc+#ScoaZPyHtGWOU zgC8y%pWQSGk^dKm9zLt#2Vi-Lzu}3q>22SUj9TBU`lB^82NGtOgG3V zcgf?pu{SKR@21=1q?NmZG`-f1n3&C2O25_12c_g&^rJr!`du54S^N)__aAEzBjJky zc_w`IEbW=q1a!nBtB5Cet}et~Uml8edN+)fC>_5CqP;Q6-o~FuZqNAl1|O)K=(lUV zKJ4Uw2?5-KZ^CZg2qAsz91&9J!Zj=xZ{Lo9!9TK)pbd{zq@UY$^Z0l|i2d{B$NUuA z&fZJkLiisp%P|j>B`-4YS;}(v%UCs+gkIv6tU$YqDEi~g-MT7EV)+HD!)AeaG1rjgFxcQ43I ziMAL_6wS_D_D6NH--oB?!0*^5vyG|xGlc_0Xh!u9v;7+$;>T%Pm9nq!Bhn#r$bRq) zuyzZE5t`-iBQ?+3=HUE~Tmkr@BBdX&H#)-!xtVIXiE(y)x%4}H$G95l@Z65?b~0~% zfW}#)zotSCXTA(QSPW29n+~6+k@!p4E;ZHeB=ymRck^_P?`;AJem6hf*)*&LiUhG> zQGGlmvVx?S)o%eHMve}FjM3l zR|4W-p7X)1+GH@n&FY-Aup%K`mCFm{T8L+Xl1iEJWMAO66nP(K2f?wg@fQ2UR9~)K_VN6Mq9CC|F7!KrZ#8=*#B$Hb<89Kbck=ad&g!p>yYBl@fWM3uA45*##z$>x+X1{Ouj)({apw$MBDS;#~BI%isSet?i4Ft z^A&3W<@ScBq!&bYn7GJ4$G0>+@_~>&Z-(Z55+U##EgkW1aq)X5ttAUqeZ;PclV#s$ zKF)UpIip!I`^?QRO)ZJd=-LS$m2elWMq(WK5?aAZfGtu09sh3(`ndRxD52+FaY$so zKE0QFwb5v1J+rI|dvj6{_$L4#9p)%V%XC}k)7=3yqBkG2W8J_z!UrmG{*;?t6LLF|tgwG$4bqUya;=y0GG3#7{_Uf`|e7 zGVzX0swa||(3mOY2+-6}!#8=XW?4q(swm;c$v83X(!=(G2QJKjuBdAa0@=jN#GGp? z%scT6*+)j-kht%@Q(kcNjhyE1ZE`xmais&)kQX*2+;fee-GeiFKGq`wH;Ww*AgBG! z)+xT4t;4!zHiaG+q7@Tu5pNoyTjy@dzUzjG#4{H_b57u@CH{H|sTiCX0O>bxf zt3Y0-kkyv2`Im3PFW)8Me#VDbu(T6=L}^>mSN!o)#^|G!nR)R~dBuxYZUg%|onS;7 zBN&FQzA8J`yNZFa$s?lmog(Z&cx{`J*`*`_FG~R!;gQlx{(fm$vLU_u!B!wS?rTzo zM$!@?`BQJ*TJTg|d^~mm*%GDKtARjpLO$L#oqZDC%K*_c36A}h!_?-0jk8kq9;Y8}6cg&p#uuJ#Mx&CKO z)Ny+Th?O*ei ziK2$hE%Ra`xR+-ay>6ELqT$*fj+@75!t;2ZfoI)vcUPG%+Yvn{7N-Aq2o_2X5)L_q zL^!brxs>_Xv|NCe@c)E*JYdw$Oi*FV);@U)ERhTO(4}zZEnu1tAlMIqz1&sc2e4 zHtTqD#jnXd!X)|8dUZSGnUYAatLk(ak3biBlQCH-b7 zPSttN1Xs*#n5+@F6J!{`U^t&u=n2~rIe~}UtF#AxpWkBYCt0<~TZD_pi&41g0Q~wT z=LKPEVu@0Z&1lKaCT#0WZrcnu;Bl|Mzd4+nj3fV~G2OwoNja7$i_31%3Efqr9rJC* z9kaX(P+b}u0cKE?jO8|B15=0{><@h@5MCCW(eStg7X`wn! zR)IO0^o2lW5lRRfByyWhftZ|ku-j>WsDAKng^>EHUrP4Hyj#>%eZEeXbrA_0yU^p4 zrI+VnimW{)A5J-(r08AWq-q{s14+-S+cnKd^%oxc#fzb`%D2iz9SF1dP)A6_>Y)&v z664UCIn)%u@kFMtMYLxv+1`*|$RH#F7kpos@A$-ANEn0UYNR*wA628o@L`lby+Akk z)FuFSSa@h5){nM|;;UoJH-D#g@L*+$&GnM{q}4)$LKcCC_s^$yW3% zv3ZFrhs|}gd%>m*$fx(0Ve3JKDx-J1UPN&(+TOaw(fj!qJv_)KUAhG}s0BGdv;Ljz zON74B;&GPxFym^qP1yikie{=A1V8hoyPeKQM{%x}Q+y4KT*b&CZTn4c-7m+eB0urb z*CFo09`D?x0E;?D$_CYXy*ym{P9V(;Yqj_;(zflIzp}8@l(*0H!2eU#(p%gytmrxv z3o@;A^jTuUangAR$%`YSs{~cE(6n*3%MIXk1@@ef%+jvyL#1o-1aQc z{H-i8d2~vlqVyMdyLJl-5f7D9SJ(N2&k~{^dX->Y%GyyK1WSajK~;YPJcK{MFFMWW z__6ceuKJ@8Uk}i}y2IhRDi)1EMlxg&Kx^0s*H6vSrfa?^zxrbHh7$KHFVEaL+M%65 zhO1_%0c0=UYd2`cgZmAz@(C8r9KBueRX1*4>kR!61nUsOgb;PStfX&b2^8G9ng6-; zD;sJ-UC=pMX>T_$o`_Z0cKk6&VQ`|tZ&T?2sBEy#uVOn2pnt{yU2J3#FmwJy25o^? z8D|JM{#5`C5k^i&m&`k955<%i7KiSHw+aevGl<8iyC6}0p6nH7e+f+3e3lMZjy?6> zD9-ZFx)JvI+9QJ6N$C>V^?_3o1KsbQ`ppY|2>cYS-zY%=fu27FTalii?e1e<=#iDy zO(OlwcI~fBiLo-CN(GV3NX2@A(8~BfNq3Nn4Ft48S)WB)ISZ62@jIJ51FRXTx8X!@ z{A2XAO+Zx7@Kb_SV_OC@1GRItlRYigW(%`y3!0U6T(i)EXMUtDluim+Sg5?n1;<>9 z&D74MOLLpmiFK{SK_;nY(Cz(WZetCD5b7PeiV~4E2OBBCc-eH11G@6GGQ&dA#5@}@ z*PicfZK<{+IIcM4oIGg(h7*n*pjn3@J1*9_=Au$&KvKH@lov@lBc% ze})BouHLwK(f`;058Q3ivFCyd4Q8Vm`G8UU+?B%I{y6b?sl0aM2t)@5Z3FjA4--Tw zA#|lxR}{n2ct(Pi!p7W!K}R(vM*vp(ehk7{OOI+ zWR}h5F5&_S($*yM?Jx33(cS{uG2Cozg)xGsYu^jn9MXk{0MZ6L`&d++XQSJNB5h4b zIHLxEPgH+k2cj8$QgIwP>X|RVaT$>5>O=Y%`(3p)Fyzk^e@OO)5|5ga=Rw=nbJJaM z@9Ii9t6I?SX==Wkm)5mUJi<3D9tk!I9CTa84IH>~u8=&tM7FsS} zZS+JSS!vk>Wq0`Fm_Kf(+75-|vk%Qy8Vzsu$I6o6S9R>g<=z38ekjM-cf!7k)gd!$ z6$12$>8Ozc9@of3ZAmo2{PkarcgM0{`@4ur)gM+rN?rB?ylhnJf2zwUk$@Sp-ZhZZ zWG5gcP{fzxwk6_=8l30Vq)r0IsD!mFCcV=!lhxwm%(vS(Hj|zAeTSv*%NTLij@XJH zQ&5jIYLD?oQ@j0oNn@z6;j8`5s)b)1@aQ|Ii(oRq?Q+22XC-gYTyb6EpHDaTHLA@L z@d*V?Zyqypky();B--vbucyk0n^e?2pY+K}Qw8yPkgJZ^hminwiKF2QLy4#Hnt=Kk zW-bqF(M(T&v!w|yX{D8wUj8Gthew(rH3j2|5%EQ`LD!c_2-&>Y!-T!}3$6X)WIrQD zx5Ynga>5Q2h2mC}dNOe6+1e*;Cp{*sUqR*~(amXMw5;_(GTXW=t;{uOlCfZwBhho%TKLxr?U#<86GEUI#5;6Kr@QDE~#I z|94H~j0znm+ICdnrldNc)@FEzCqe$}4^{h1mV%B6Vz(nHz$ti`{~C+5al}DOv+zSa z?CU)4MAAqKFkA$Gcn-{Vz|1@1qX8gJ7*3Jx?$$`~w)I+cN1%O-b?>wb?Ix8%*%Vw? zJpvkZ%pl-*t>AsW-9Sk<0kmJFS26D`b}J)Y7+^SPX%xf&O7N`HyOEi-DjUzf)z|yI z^oP-ePU~VjH?L;=PdJyX>z5D-rAlSx$@*u8V!9@L|BE+iK;Kq42-Xl`M>i&hpnWR1 z$R^3nsMTTn9VD#x@ha=~Bez|Ci%ZN4KO6Zv%2=j|{Ym{=>&i4LVl8y z{Zd4{Oj~Ep5T`1L zgHy#~$E{hddx*M5;eN2PP)*N^b3vPEzG4Xbu}VHN$Rx02?JsWr@Abn_nr!Io{fImc zbNf(cc~xE+Iq1C4pcio?U0}gn9T4-ecrZX@b+#*l6-+~YKMmkWJu7h6Mppur&|hvP z1&wN5lL24s4%h?Lu(QC7hg@&uBQ0f$!(Luu3UHkgqoqRw#Gmg-JJ0p)PmINasDTcW zL~=lFIBY`SIk59e%gpmkZ@-?m^po9PNWA$YvmYxQQ=WX)uhI*Q62A_(`UOh(qN((z zF#me``0-%Nn`(Km2B>IC4*b7SrPk%M@Nqgk&YHz`dc9baUKGAOLGHng1!W_j|8gpW z(m`tyjQb^+AiAgCfZRl0+QOZF9S=0*Xm#OJ<>~b{wqZT!4_Q{wGG2}zb5uosSdwqN z>Eg}S&?v&j8&T$3e%BdU{Bbs(b9$Cp6MLksV6CyA*hFM7$B82~X25Ee5K0tHm!Bo| zqT6i%^MhG9%0zeio3k=SmmDc(+_f`o1WOcP^v5xdPsZvDB8-_)AUp?7BBLYyY*xej zyasC3M4YBg^+>~Gqzy#3D8L268%&$vh-&y|o8HgqP6tJepJzsvKBZ|lI^+RfZhduv zNYH#~{W55fNo)yYjI;V!{XpuU&-0ifd@eDDq(SQx2;)YVikEa6!GSo}2L3ecg9OUQ zlv5H*!Mr>e;q0F#Dpdd=_}Ba8u;)g^IFni zw+^-=U5w=MIh?D8N>oW4areWbnYaaJ7LxO1@4laXq%|=v8H)Co4JVrYsdTDvbg?svcKlX^ zJ_je^6Odw&*TAI+c0`&_^oKzJhZ;b|`-aC3k@OtqXznJD+BZ^-zZDx zD4n3oAALQey?XN8!xm;ods9g8z0^dfh1lVgu0~DdHVBzqXV90}=6W>5_UF+oHirk( zDoLMy6RX&-8>Gcy_@u0Sj{b8b=vSo77l7^ zc!3&+vb<%CJEZyLBKrBynJpf5d-`c%%|hHbnp;E!A3MW4wsi-Wi)Vy+fr{&_x*C`{ z>&%#fJ?dclMcJ~p`+eC7xCWkdoU~KfrdN({C@DZ(qVKu??_K)88XftVFaj{8f)+T! z?EUM;IuI3XA15AmTdhzB-qrV^8!V#_3P4@}_%;sHrq~4gtIh$X>KkOHm4<%^XB&#h1 zPSDVnUs37?ZFr+H>9+9LA++Sn?kD<|C0`BdN&eFq@XN)rJ27e6FDiyzUBiN^*w$Pc z{9xLWDSg8h{Vf*)eHO`*pJZT_H^vnZDfZxZ1J85=OS(zhO^8hW5iJU0Xr0-?~P>Fm07JY`>#mlyopTnz# z6Mcd6dS^Cht~#*?Cg}vOku@6ncKNoSF4K{51zY3VBMa(gUF#?LuPs^TcIU^dTLn9G zD>OI}E3@U88%qcyM@3N7guxXSjv<4N-5DQzef(iK=d9yWM!_8ueRw(_>Ul&@$H^%# za>`2i?j1?1K|O_0q9YT5$WiAt(+rPE@r93HFH~Op?~BL~LZ|y?xZa(wUl=xeeilxb zG4C@VarZemc<4Php60K=212ME9oli*{T4_Y+gAi7mebIiX z!QuL@K=;IPk^(Wq_6K9MsL?e|lxS49P$k+nx4_Cu<@|^2!gNL317C0_)ple-KyRh6 zzMrHw>Avwf^Q4w##o1o7d7JJ(!JuM2^u*0Oxo#!qY_)#23I|$;0ocg zz!F>KUdL|V442n;z9c)okvMyViQ7pgLWjuyFoC&o7{s_x4w$bbf+t=Qhhq0+%jrdz zY1V29=Eqm8$iy5jUoX}wrS=-Wl*8Q(bqlmx8U~4dUR9cm_xv%8tJpF`c_rMj#7?rD zV6BO!j3|`5A;rVl5^wRh%t;tpMucn#qf9&uCl9Ypw+CKp60pX;{8akRvXekFRLZnH z2I#BRciX9{Xo9z78+Jf332#1xnn;so?ImjnTsKtPe_ zDc}2^NEs^(5M+ac;0q@n$AV5-&!Z7w4yff~gwJ%kL?go+7~lw0I!<8H+0PSt?lWVW zDdpG{DAbw=AeEaL>P#*S`cHB!e1x#8n?KJ!1&enyn8}8RTNu0+gg#0=7LkQrYY+pB%Utbwk<+io0fTVzg0wSGCcej9aDh<-zi*89lLb^j#Lb@9y zCD%ffPU-HhZ!Y(H-s9fqJ>PZtWBFsVWzFXqbBw!&y>vthj90*BMb|{=r4jbCX&;Bf zo%r@GJK~iI=DC`imj9pgwG-~f*~`l{ktxZyP|eL?I3`(EjK#OW1Q{R6o1v9l(uX_28(t{VEcb-v$CZS z?%z0($Dc0B{gU?osxb8CfYkW6&^QGVzWfxzhQO)P%aq(U25WIML z5iYD$cp?O?#WIq?1g4JmRi;OLGTW#S(bK@}C9UwoNfBZ%J>Wm7&QHgPbfy-OidYhH zyP_-A6*q&d1&_E_62I3DS?1T5ctSAt@cB}a*40>68R>Zq#wJVSzn;7Qq#s1@qfrm{ zQZhQdjIW^!rX%Hsz;!hUIIOV?sa(O=_c>g*#@ou<)1M#`kLB)zYlZtAIuUghTreT_ z5_1-=0RjwxA>At2b!i89L;>gd0F+dq1Rr_+^DQUV>MO#=t!avLSn9BNA250O5sjR+Sml!YJC=5uLq|!t-#|A##_9@5P?p zTS!#%VVie^^~>=&kVLy+MAZ`WQam%WYNm|d&P;mJo@qSmaHi+MVX}7T1T>}4e37mP znkX5BDhC8Zz3E>cyWCU=DJIX1Rx*SfGe2}tR1iY!z=P2!2J)JD8GlC+Z4_UxIwS1u6nSGgAgSo`-!BwVgka!< zyDNq}hL5rnJX8+H-waxPSAZj;Z|wqrZXUzK$e6#H6}kGuA2JLWNAL3$rBqy>OnF5q zri@x7HbB;mGJQ_8qPqZ)bDzq@uIwwa*=BJqHzEqwJ8%4+iw~*bz*I-yZwj6Pq5A+> zsL616G@`U>^(cj3$};{rye}A@As&?fTdVpV$=%>r3v5o5MR6+rd6uZ3{643})SA*u z4tzkwu)}Fx54Xp&(~suA==(Cv5a29bw7u=;R~oN+K)JQZZ#3RA^WG4@vlmud%yxI( zsc7LTeHNo5`wywvFK_i8P(iTqYd)!sl*6QR9G9iRPMcpx;}4!b;r;^UFmVpgdP%5L zvzU#TecQozO&Tue997iEilC>$h6HXXs7QXa?V^&zq^FMLYDchz!##+WatGnWq;Uy~ z#3ccr`7Ph}UD@d6Q6`xb{3q69(>hCzKZ?DPrhT5xsc`-`=pDQ!qKv3jHWTcR4jD~i zi?tWdWAA*-iGt&2#vY zGC`=2c3tM6gG`U#rRqmGph>KQl`{H8=lR~9(+h{}DGXiTdru~>+k<{7M)R%Pa(HKS z7`Y(ZCTdSFFRia{uDz_CgKiOuqKQm;H+3VlO370u5icT9dJ>sO37Ng$U_4Q!>P&3u zHv4L?4t;|Zu}kxtQ1bGu^79DJD{^@>?NFdi(>VUN)!`XsIHI^`GljjJ0;Bux@`Bm} zcm7h%049i_9`HS4*6ch4)A00LR!H@#yNFo5NYhED)>;3P4D8e38dS|Np;jhhKfY|a z7h8-a(fHpZb`&l@Ug-7ahq;d%LyBXoM9xduF_^vg~ z0_hIPQJ1o;Mnw@O{AnfS@Zte#YInRS~i3 zko=QgsRPjF`nV{aWzGyx%lwr8sKTNT0~BJ?5lv$fqrb>s5UU_~Tj}`aH~aB^82JIW zrv4JmR(V2Oz{gFG5K%Vy)IHh@=V?Y1eF;Y}lh?y_{?Fd`;BCH=1fdP!V5t$(bO2DB#t1-6qPz zcyd}_;;D&~0f{y4rHodu90$T5{CpLvv1E7oDX;^gx4PF?5*rmPvHEn<6h>wc@oLU- zJxyQRzI!(qxgNfLpgVQ9&*gkz%L8|~LgWK^5J@b2I0*hD6i@x;dWRVq(-n#^}hOh2J1oQR>8 z=C=99#$rRJVaR&O8*gnE_FljE24DR3oyTO6wiNQ2&P$dLwUoX$aU6`AjX*{=f)-y| zN_MRhUI@pps*iPZR!%1`vK_aquj?qvH^;Hry5ZGEns(K2Lum?A`f|{Ph6d zp?0G*B0VriT5x3P&|61>Tq?>K4h1yM`fEtk1RjyB z)dXiEe~QzLT@qqk*@fBXFf;HNq#ef^8e5>~iu!g`aR>f%HsH@v2z=BL;*)=pEfOL=rh=NT74z_U^;n}<|XH-S_4;!nEb z1yJM#417(JzA$Z?NsxuUN&7gBq&n-0WG5rMS}!1kh07Z=^}^%tPuu^BBH-}o63(}W z%a-fkKb7k`XsTBQV-R#Z3CUy~fyGp>-=zD{0zs}7KT2RazD|{eVF(sUD#S*c4E3+d z#Hs`%wyUGq6Py9-JqFJ97pX`-aoQ*W`A}mUSMQ%4*k8kJOx&H_Lm?7R*`Zg3Mb-%S zEe;4Qq8o(Eq5yppV*?b?@+UXl9xho%2gW&&&y~OU^;qo_j!T?_U1}U`Z5B_=XD(p< zIqyOUo27RRRVDi#A6=q)YBh);B$|fxe=Xjdx;Cw;=4O!$E8$pZt;gHiY(VUyS5j0G zUDxb#ZZE-}c-M5RYa|rBeeF_XGa~iPW+q=H*T7TgD32ClGv_OXa)=q7uA7oYA5CKY zRLHenwaYHmA=w(Pb(nQH!By!qRWpnM5sPbAEnnmso6U(NZjEW~rYQLkVWeXNwQ1qL zq>wwpS)bY_rf_dt&Q2!`>`w=)OS`L5&`Z~a9jB1OcwN z+ULI-Q4#?M9ys(B_yy8{3jf|q$?nlgb7)(A=dll)0~;_k|NU|hox!z;?9f9Z(W(vo zIs687{*o7Ys2+WiAzz;x{^aCif~IRUB=aIMt_}UZB1+Cw4zCi&6_air`q0;6$Yv29 z@)Xj<`KE~%Z^-ps%k5o*28|)sr<@t2ya9-?^%C`Uih-1+GzdumB-XrjS@wyR*$-=)q^J8%W>IHIY@-S zTo8J7Ot@8{S($6v@0~o(ebVGQbsg4j4Vt-{3>~t(E7bHlAIB)R=bkX_*M|>~9OwD3 zeKc#hYoQ}$N^dr>utf6_3pgJ>e0lZdW>RP&r{lm?d#6QBpA=t_`6>ySI41{ZXBVRO5nmL?ThqxfxanGaM!f>b%=(t=Nvm}Wb!3ybQt?Fl& z*4yu?T*!1-%a^p`j^L6>_lu)-v-5*xEjqnWERy>fLAlKD{ObVJW~LCgOOfSwfUu>n7zfXuSth7na`+0TtQ^CIqk_}prz$Ot$mQxcg*hD+1O^GktNE4)R-`&dUYjU zpB+q_B2=S8k)_|JdRCUVvy#+UBkUH(VID9KmqM}(2A7s8v{ zqrK{7zo_VEZbVW%SWYs)s_OqgiW@-yCTv4{4acMURjw-DI1-{hno!XXL~B9#ah8EQ z+(L#vlJc(bs1vg;>kWd){no=Em!h&Ef|{+GN|&4Jde@pfJm^zc$xR3w%gFv7A20cA z2&BNCDKQDE>+pWDDv%U=nPuFu`EyfLeRN)FmXGEa=3LD1B3eGtS- zQ^<9WG(HghvoicqD1M}hYAzKM=)32P8m|hp#nODb_T(FITmmw9sGF>nMkvx*_Cz*) z*cXjb?r6b-7o+k3v}WLN+4#YD2_(g&#H*i+di#~}1K{9^E6L4D+PrxaToNz2?c9>F z_j=tsZFHP@;OP;Nj}?@cKK|S2@%!Ha)PgwGV`9?cX{$B`eX6*x?+{7}3pT_DS~0@E z;XS;tFO@NVB+uxQ+LSaWP0j+?E=I$T*o&!xH|ZBwm@vn>n{;YylfMfS_Py}k&XI}j z14_ZwEKOJ%Y+1P?H>6zjrT(u~8M=Hx@~r$6@QiHfiQoGI30HZ*L?s~CIO?4|QDbMY z!be+L3vmV6G2D7mlrng0!E;&I;=Z|0o?m%8a}E|(T%JC&BHWMlBGs@Ae`|kF2u(Z6 z55~ywnM~Z{`ufhZ0v&`VyZ@u@c-}_kaK4=6#j|s`;-f*pqOYqe-c;bWEGs-F|5v6` z4F2y;%=4=He%CI)rM?ZS#3I1U9E(*CZd{b8o_r68)fa{oR>L>-K+U`uA-C^tmhkhJ0L`?YdG;G93GKrW9v1MZMumj)eZC`(B~ zGGdBy?+*|ux|C$NaP?kTh?d^!!=5)YC$bm_V_P@iUGV)_{7HMLDIF)=2gttyrg&MK^?tEzKMh^UKf>?S>0x>SIvZr6R_1H=x zJ!+d=lb?}cpRA*hAk0|Uni@Hs8qz>TnM9IM#_Uoi?x9pxR8OcNXmwzRp@?3VJ&@1} z;k(7|lm6Kt_(oGR&DLDay0+hZqI%+L*KiL1@zv(L?HN&3_a`SMMXV^s^%)166{duv z@m@GT%xJdWdGkKhP8B$0RTe_c_;mSZjOG33GSyjR!YX|ME**2IO4{Uwf7Rsvwt~7a z64Q2W0b&%r+M@uear#CL?m??1j`3!*jonIQZl9IIyJ{-`(WYgGw{buPjeYkc43f1f z%~D=|IlJNk`jvS^xMMdJG+7#4-wl3W*#Vk(^De?KZa}8BwFVh+iUrJfRT$gg7*FZ0Aj>aBMku7@AsQ0BnwbUzitmD*%L#3MaXmr zpty>z4Nk^4MlOh^xN`6#rq&AuIX6V^Ek1x94>(aJTwl7rx!g@Mq6b-@Krb?$36H~g zHUTtLslq-DeBjAa08%ax#^Nni@rM)aufYdbZr;0Siv_lweL~vP$y|d)(0wUJ^k7v) z`9k=2k;+y?T{Ao4|5_9Xmen#5t{T&mygj4nye1bn$FQz^DsQMdQ{Cs2SIA<3EiH~B z8n;3ywaPEtee#n2ygn9%rPf1AIG1HQ5qY8ULSp!Xgw&Ta{4@8-%bcs@cD*#A*7jFM zO7v4hiPzhf_V;Iy`JYVUOvVHYrY_Xf%(d$ka`+mX@x3GATP`#f>Z)1LSI79!%Onao zE{-pe`~7><6Hh#m-S-~{Z)zNS9-5k;I~-H^s8-#E1@1FPMrRW-swBo|-AU;#t69C&Q5Lw>{6CliPEmx@nI7|`dvbhFq~1hRfBD@fi}IntZLps~NTXIL z7%0gG&Q}jfvk!WKb>BXbQA_mz@jjWbpo0R3`4A`1th;e<^eWbiyle2v{^ato7TheV z901CeBts2Drn~%8u=O4myH1HQqG4NtrKYt}hAiFRwRoL>8s4Pi+08b2z|VCTxMO3b z&GQhAB#)i7#)e{X9`r<$wT>$Pn*Qw&f-RR}5HNEBOGi52O0qFNW_f2iSNcCX`;Vxs z{A5|8UF1BQ(pF~s>GE>&m#^0d*J}24->4lf-K5s1&K#yT9cd~wLZOZM;ek8s={Gzj^41@}p$X?IxR6bJ}&wx~p1eis+ksfG^^)K=35DjM6zN z=N(Vfs(U|J5?kknoJ#noNNvM}s~`J%o~_bIqD=G3S5B4jF_GNeqIx1$AK@zf#Yyb)vB@En+L_v~xbM#*ek|qBBMpM4 z)^mEy@e@F9lyFrG^D_`7)eOtO^GQ(H{zAv^`d9((XorJ7fLgEyo=j`kX-DZw;6+G0 zQ2uEgTpY=>dKl6XLT$(>JD4qeQvbvH#V_}FocOe*%_pg#G(=iXCqpG;|GG9i6Yhv8 zkvMkZtei~QL$%vMq=+F{r|stq6GJim`Ezs+7_XDzw$*A9+A=>NwiOJGOtbrS$iuPR zj={nS{%N)F^lo+rS!L}O@A{lvLsJ?*)xLOO3L68cEq97kLLnMt{d?E_$DH3H2(@gV zuK?zdmCWbp{RCu0f#U8=0QjzD9yuw!XX6fmne6^92wkvCC(ebix1a zRs-IIwHc3g*9}V%r+f`WNu$f0XTnc$0w%IlVB;990^+U84@fjE(t}wdBE}y9Ki26D z*vFNEyv)9`FaL3Ypi!35j2;jkMZ-|C7eo|uRB>gqiLInWiC)H@K9Gagooq!AIa03Z zxcQofyv;uGG{g?6+CE)uh`=%lGiBtV-oLT{ge!(3R!p%K8f{xgi>bCh#oIv$zfZtE z@{v1vT$y^H(33o1!bodZAM9rTz$9-YQln6L)L@G^mTy=lgm}$b>%vCq0Mt5Z;2|1=1q`u(u$WYjtNotlP?59Ea3+rS5ddQvjE4C` zei8E6rLR^k3^~JN;(&g@tUU#UsO-*hCRwHGRq%g*AO02< zNg4MBEwcOvE&cbipa_466r;Fku*rXmwM#?n=~ z3`p!bxU1y2KMOp_y>oc?`As#9Xb;?uTut0ST%1K%LV~cu+`Ah2L=d2_|M=Ye1Ip#Ym&N+<$3;mRD3aiGS0Q< zr?l}y4@bYY^Xs-P$uyK)>=2HsS=M@R4{Suos14>^=I)r=#IRDd-il`hg9xi1P*|d# zJ~LK-EcePj1LDo+`F$;way(|{%v^j1WBPY5zMYp28SnUF-7t%JGdP%)1RO5Bx0uF$ zjDHOkQT*PwU6#F|EbN5lDZrL4>=NuIWc zR#}^FBj=Ooaf%uIMEqg0fAs;slwQLV0R670$XGL(;L?B6i@mMNtMmMm3io{@=e@5e z#8Ms4^7g*sXy|o$B~~m03YOd3U$O+n*W?tv7Dg2Op3;0E8D~7~@bzodatT7#U~O>@M{C0?;F-WKV)*eQZx`?*!TN3( zBrN$pAYX2P$UGfK-4v9&6-ks8FKyf4+e7xkU-t<1h{`6~={w)4|1F^w4%!4Di_hOF ziW9lWytj?DEYun6fUvFNf(pS^9|~PaR|i4E$|!I+9VNykEEX^M2LJ#I+>2)22ve{g zs`pHMXm_c|&M+cjpKfX$vX;2zqt9qxdor3pf=jDbmKmCpbyw~IgZ1GdTKns%kDG+H zxL2wLSbMHSNK_)!-70JDXCj^BO~eI4&^oOC5RY)ZR)rZ)9w<1t%`lOxHhCeUNrK6k zAVz+KUV{wDk?vD!5jyUiNWm0s$x*x{4$O6@V^-)_Zj!!liH;AM%?#FLG@lTu6cQv4 zUDnqf-<&XB22a2GFmm~#G^pf&N4ee(ZC-q1o2#U7M}JaKO@oh5$DD2>u|r>{rdah4 z5GlZuyp4DQ%;R4kN9z_6pIQkj-+$Z$6nZQEh*+#(RJ41&lu5`arUsVSpYUP7ps^E2 zdC$6l*^2vDTf6k&^--~H?Q&p6r71v)Gjn^u*ESGKUR*x^qt=mjKD9+P7x&tZk!bPWhe(}VVm%<6= zeg280d7MU$xBC%{Bd55+YSAd4)2~}yB&0+}iSHezmOJanb}~{mrd1`$UJbfFo(F+Erw@e7%M-+s&sCI92~IZdJt}{8>#>G;*TLY! zo9OTrJ;G|D*084q5m|S*vo{*l8Zuz!!XhRK|K={UGtu_U8oZYPq{R&)82j@q11+69 zFnI`Py&H^z5o+l?MyjDMwT+G2HEBd5zD*M#NuVi^+j?{W(AD45o%)`xRP>)smHu_R zkv~NO>6=z(r2Y9$V(q93@JKy*5D+u~`Vxz^wxnp-{@$+NZR3RJX7M0RlCBY<$}l>~ zDLi&E!c?aIGgHkxRCPk!Tvp20q|-onn#y3c5A@tA0Na2bZH_^mlP^JfWCcp-C$H0; zm5a?%?4mffWYO{SGVk+)BCOx!xI##X961^ogmpvR#tl(eoa|r|FG6=gS3}(53np)( zAJnjJ>o#!NY&4@}xc_>)5{NCFZ`ZSXr=2vt-5BX93DF^r6r>4=wlGg=Ul6J!_2eCL zQr$berXT|Wf(=A`P9MSOZrkK*=yP+B)SAq!7v2~OX+9psc;X63_5gWZMaWg+e;_AF zs7LKjNCRh4W-$7^hOSTVb^OQSl5uq3+pppE;?#{e~RtrO=XAG|6ra=TVHkn3o$(M0U%3ZKO>5q9tR0wxo!6jPw z!eW*qyYiREYDSXVWrG#3_t{=7$wpq5FFOc`Ehcyv%hUIhTJ5tuu#!8!b5nC4S9rq} znR9W4YLRugh=2MD$V)hZBqCj_$-UeX2(%Uy>i(xrnc%LC6GMSo6|`OOilmUO@o)P>s>F&s0sGsCz!FXi)8xB=2_ z@Y9GZNXxySf#^}3A>dY$bntj^(+=hmuuB84tP6pvZp? zfL4`syBcDC<9IU3y-94H@;QpmN|q&sli8 z_e7fh7VZ3z_K0t`jL9cINy)C)10|*pOEx?%pg9}p2HlzFV$(Jxjo#?Zco#w#v7U5o z)~2w`dlOqG&Of=tb9wdswkPBN-Fu4uLX}VDe9C&z2eJUyW(Kwm_LA9*DOdf+0r=+( zQXVh!v}4)rEmjqL;2EJ#vt`mz8oG`^T9-$SGD@z6R0Y*~fot35_8^4k;`az7iWIBl z|Gt0_Gv@u;n3d((5DdvkJc zD3d2S-m4%MbfIQopMyw}620b~cdl}Ir`|zP%fa7($YDSNTgW7e_g9!`o~OVl2rZxI z%Nn`ctZ7JXB&E01amR}4-E*0q0oJ_U)NbZ@6@|ruf8;v>`C^mXRI`0$Hx_2u-5ow2 zkpQtG7X%bM#*E5+$9>GBmI)~pJP<=k4Da@p&vc} z5Q9xrgMEo|Y>h3Dm0w0iOf7#FDUeI97&O}XwG~p^tg~8M8kl!&8-?r_ge(#Z+a?*j z(nucl%Q;E9f7;RN*X(+)WJaiTy2ZwNbM5}np$$D}Z*-D<K^__|v1e64IzdW1dBW%EY)9B>mdVRw=c40G>&Ar}$p1BAW&AZ1HTda>d zp9s~}NpeCoQS)ysrVVs)2UkX@t$xitH~58X{K%NoBlYavn-7#$x}x{o9K|Gaad@T!$m^(B zM{ehK6|+({HX4M4Bu?espg{hv_A*Z~qwr!jbH{KcQwL}S4EfsW*ih~h#tf7<^@iT( z2EmDLU?5GhEkgUkE@I+N};xL zw6}fF>1Q0@DIQ3N5cS+Rtp=%-eNLf#RwCOVU}ejX@5HcFtK0ZmZp|7tCw@+A_C3ngOtN_? zE;?2(%N(EhSIVMt9Fpsdi$)I~mfv0=8E>Yn2KoeW&(DBZhZ&H@t{(1S8wIB?PC>33 z8y}`4XkV-k&#LYUK6M8JQSVcIcCf4x2PX+@)LHC zwcW`8A)XNz)WB3b&A#naJ<$SE8|o}aKG%a=SI~S5%yheoLdQRz=oAo7_W*_HbgAF9 zbm98I&=sUq$Y8NgIyTUxJw5wtphi^;7Zv4Hna%np2BreP&shz>rOg{3IVY$Cab z^>D6rz;?yQ{72vHiKORd%gg#u>qt}l9{c-fvynRcT7R}T=E5#_E)m8E?mq?6RE8yYL^C9y~FqehM*^IZ@1b5aZ; zeC&4}_M;JK zO7+7}!`Rq=nvS@Xlx%+b`PYT@11limm3FTlXcKw?`2(7$&AC>;G@hQNE?C6Iv519XW?=HEfP+`uRo zRXz`G^|^>YoyaWr*xcNVQ>*T)P+?gEi1<#m)0a=YM2tKiwEHEDO~#2<{bx+d?ld##(pIl+#o( z;s%re2?h^otOtn%xv$$H*3Q^O?422)c-nyxQRa>JWQ%r0+$eN}?i^MKPsg(Zm$2KH zk_M~PFaH$#B}mL;F$SX~rejwlx<4~_NvMwjL!vX<37GF)JqO);*OUI6ENYybIP4Ki-|D6z8pX4Iu#-zx%D49CMnGKWgqQ( z-sh6TCN&-2YEUE58}&uvEyqwQO~Ln*C$W0BC@kPH(qd>IBtc(3o_p3r|3;bu9vtrBoU@xR6Vt=v@?fVZs*9fR}&8kfRV zzBu$e0ibYywXIZOA}iCkd$SzT3Oe07a8j)H2$_PFUy+Gug*R(JGvD;oU1AEz^W)4}@)m%B*Vc*B$OdZ`-?x`0sk1@VHleEahoq~r zG)+1EvC9s5kZBg}cJm@op1TC1U^8D{rHUe11>C{Y%LId(_PA`)Y@qKD)9vyqG*>WV z9f0)CEX_x;mp}%K3xYyEd*vjj*MKxnep+Sa^U6&e=_d=L+FvSm;mM9Saa-%4>30K2 zy>=|BkqDGMpdu%Sg701hP`pRw#YGdcQL6YNkI;fRbUx>mkD2kkSxO+?==!@-VHlTE z_M?_DYw#Rrk%7l73ogBy2AtsH3b4LzIR~+b)1HCE)a~jq#rP~5kaHj>tnutvvhUVw z|CIge-I6)gDJMvuTsq~wda{sNN9N1Tz8=#T_XGq+ub)jk#R-t5&c3C3`RnCyYB-I5 z=<#+q^xCrPqF?DZ7nw~Skhzv|M<_pAW7%ysjK-JXBCF59Y6v|=(~b1V|H+rf!Khcn z8F^xP_)di|;aQ165vyIzoXsyaru!{Natx4R-7#zih^?*Oqg^3Vj6uKet~yk`ts7{N z(hCv`M9TSTN>TUw{0}|DP=aKLf~gaKi0LE`h{-|U_1lEa-Ouu0L}m`f_7k;b^3ADd zD2VQ%W2M+LSN0Vxx*vIsEV%Sjdy4mBSte}IxPw6a%f7@~mgcDg{pm2q#_pi|xHam` z+vMnKM2^O%L`%dAjD7EP$a+H;U(au*q!;SHYU@ps)4kS@WDX=tFYkD?zbVXPJn?HH zHfZ_M^pc2!h#du47-wnFM!NcuJkl&CKk5cY#r2u@JCtkh#&hlU)0ExAHSat8J?ZMN z?gw}_5%2vv)K)PXD=o+?fRm|sWqE)-G9LR$tK0wAD!W4pB{aV^j@MS^5}*?MDH z|JQZJCG9T~4bQTDdy!`R3)N-5Eua@#Y9w^}D7 z$U0`PB9Xo;cAFgJ#bO^nwqyy=KGQD^K3!Po2o)$~kw4O}vQUXF3k@B3si#UBN8NgR zy-R0x`HSrIoUgrkUrga5Bj~-#pP9-4KY$@4H052qpvOKxqd8(wmN7)Q3UuJQRjErI z(?9Ejc$aFr3OWytccFF0zH8vcwQZF}YYNagy78}O31-KkO04=?{P0jB^wuz5VdDj^ zSVMjHKz-Og7kwPmO6HL<$H;p(cMMb?(&;a&hfA4}w$_(CQQleQ7s&aCRed?dm?f~A z+Om?%?AyMQ=u1lMDTC4U5yj0wAp)8kQL$Ho*LFxpnifnic$(_4qz@MKCkA7r^S9We zC`z}=(%Zd@p0^FC3i2KBx$7k?YGp){8BSc0mS2}wdD=)9AA1>Z5Qg3KXV;3^pYp#f zeu98`7E;C&wl^7STf%QA4NLhS(Xo2WdT7lm2=CCj}49Z67zVxdYCh47p-0QW1>M%>jZ;B9Ki^_ zrimacx}`ar3Y;V{i&a|>gKkOU-x4m&c-w?>Cp+1cBVkpR2hmatN%+vCj3vKzj_esT zot7T|yq%^qu+!{OYdGbdN`kJAx`)5U4YTsOttYU)6^M^*B2O*sKTdG;CV6vmnk_il zGHL2#^IF5kejA~#&aI^cUFO@Q{i5k+rd?6#Edv58T7CGZseMgBJmr)y*|&}hv)9*j zQN`XeS7~o(4D5q%XJAkrqP(3Uina(O+Xf>VO>r-M4w~dwzvYR4eU+j};s}DxEcmlU zu4_5o*`^UTp!2i?RtBaJn@FXft(zr{2U~i*W&7R4d3L9OUXiL@(6b?W=OADjh`h@4fm9Fg2- zvR%)eMw7Pt%g-anvq!tn7GM5}<~eDBDh zWUGxOHo(9Yl}FoY0`_gORoc9K0fq{6mej;QCNx`eR$vq&@bdo#_VO$sV?O>7^5SnT zEF3fX>rX%`Wt#-ePH0p>CZ--j0ysC`9>Mn z$r5fe;9eH7<%>d~U5)gvF%<*alF%Y4Fl zJ!;poml}Ms$FJ>Y0ga}O_~QW;4aKbkk9h9kCz{8fCwA*sNoy@swpzD__qfbKFhiaW z@H^&Srt(DlImi3}euGu*y70+Ap74)&FTBe7>KqrpqZIs?Eu{^}cV{n8Nsnq_wmG$8 zG?cKHXsl?bZZN%^B(d}6;XCl-fFK0yWU%Ia#t(49eU0$_c{(}wBN;&f@=)_-ggC8O zDa{LsE8)?&C2Z<_nQ4RK3`k*PDsn^DH|KyT0UB+O z#22>uC^|hmr0qnSrQM0r4w_Y$ZtgBkF7RWu=h7U~&;_BZt{N|`X^LEl*B1Q0Q(Zb8 zoR`^^Mu-z*^uWf*LTja{NzJD5$3^JzU-)Dw-Wg-Bhum--6D&_%d!|O)Yy7d0>3Mb> zs(f-==-#qyG^x-u*hJ(UIWY=m;wA106Fm61lYT=ZmslqgM_Uhqu(*3>6CMM(NQ)Qf z4GwCqKv2f=gh{?WLWW}oSdzB(SutY+nD7;N+;((NLB0v~G6=_9NyeW5=Ac@TmDm8T zbGp9Wk%U*j{5EMmQNsC;DIp-qLi0K>H)5;K$@y9gGMDW#Tt;O89hxJ<2V!@7PE~(* zy!JA}H{fC{>8^#g;3yoN>?Pmpk`Z#MrB zOUyUB8SxrdTdsX%_$+4-sV?Upx0yV89j9A0b+3cE+BBld4F-Qk$g{Z9_x`-~6pMAC znAy`WM}`jKu%;@iA1Nx(k?ll&^%C0+@co`Bnz24LSTC{fjz(K{VA9HIxqZA?`6A-0 z34cYw%I?>n`$JCX`a;)R_wXldC>{l05QbmNtl1H{ah3FASZElFGn{^RK_WAGKUm{h zOeKMf*Hi6gqb)!2Zz@X^X;Soc_d6mPaW!5O(B(T_UmRl$4%%kEA*3a*i0gf)$9I>F zX(FuGIChc(rZRR8#9MA4r8F%KWGcs=bw95aZ&!Pv@JiD#nELHmvT>9n*oGGvL1tz@ zo1h1FXe{plKm)3?WB^zn>V5Xo+@KH;pE*4PqBD2E{OkZ$30PSz0TP41)V|IC?hb(6 zAaK0{F=tq7AEUl|>K3-|9^z>)U45(5q6;{4YrR^#GfWMXUNDyInhm9=@JswpLB&g2kDdKwd$iS5PX1Fkukod{BypY<9cJ(2I7V%l@36lazNm+lct zmOkcmuZrto6BlE#w4V~pTO8$%Hiv8%S5Snw?r*)j5|zI`4*7H)+qq+-xhN=U@1I&n za1qNbzUDrBs{R;}y`DgOFfZod(waqioT<@pia4#ilh9Nz)c~;@ddfw{Fc`XwSQqT{ zxKDDlpuz}^)5x@GN07Z{$V(-tGNy>LSKq7}DpBJ<~XWK=sUBVY^S zG~Ds*2C_IB;DMMKu9KE_ftY7SWuvAO6{b62afS<{1e{cyvgNsSQ5e^m%{nk8vHa8x$G9>yb&8pm?2VflOyJlX}%u{8Xmhg-bHd&f~B#mcuywm zXc7Ip)Tp7EL~%q_=8?pdbj$fsi#6O-N>u4o8Pnl{?VR$lm@Hz*M* zYdHuewd;)ltvN#@*dj;=-60;;_X62(u2biqKW6rtOatCI*?e~+MA%3Bvh<|}ojUO) zOP5OvFxyP?wCrQLEd&$P2%NKiJO^BIBRpa>l0;zCXHTqJ$edwX!D9++0xA~(qJ^1P zI=0oiy`eYHQt06s#*JWQ4x^0J(dG9Iz(qm?-*VG>M?&JK0UkQMB++sUpsJ6#i8&sB zNo$^GOQ7y?2%OyJa*gSZY*(pO-_(~$z%2jbEdRG%`qu~Y0PGyWy1>)*%zJ}=4HWNi zaq`&_n#i)0%!CO&NsbgOs%MF#KFK}Lc15GVTkcv~>^d6H+mk2QwEDHogarB{J>MAZ zaZB{iVyJ8-wkY}Rr;1#%vXG6DvLu0Qd2MzIMDV^Pc6ZixZM2Xr7NL-&F&W<^*eqi6 zm^j~!&+B`5*@1Ps?fiPiypOwT=#&q+#$Mb@oZ+Q=;f}KM8O5WPH?`lkd}D=lW=^cV zs%m%`3o1mWl3a@ACOKZOo}LQ4)o}6E!swqx0b4;#CvWOG>;xM!@@g%4V2N;#s>)HEBvk_lnGqM zt(1fRsf@MUg|AVZ0+T6f{;_v7OR7mB2C?LX_yzdt{qX_KJW57ft+!RrfDr>0ac zMk?8uZNBu?yxPrX5f@m&hWwP3+k6zDNZV$D15#Jm@6yiLAnTD#GMMHj78%maTs2gC z^n}~j^2dcrNo0jw^hQ~{YTjeP-wa(wJ_Aww3nO zJUR)r^&3cXDcKp!Z1%i)tn?6|y-a>&g*-T6dYj3Svn?|%9-V)x>9g@rKW3HWdg?)|bh*N;;s;DHxtCi2vHrnq?1Bq`K|328NM5FA;U!?x zW}n6AH+$BDP2_c4qnaK2@q)A%EV^~9GPJ$`ew$_}ZBk`L_5lUop{KJ#=wv)M+g@YC z8?5!30jAWcXCmzDV3FRGNu0toNEg`w?=@G?<|z>9HUjlwKYY)Y&p|KUz2WPd(^j`z zfE!N37rgCB_p2L}m?yk^PCc#zP9I(q;lrEC{MPCBjcpe-!F#G=98O zac^ZzjM2xS(MgxP?QdKQ6SaMiyscZ?_&)!L%O(^}m(9=!8IOxmtV3y%)$g#p#^B(2 z!}h-Bkc0!>KtleQb}V*W6YawL1wkKshI}ac;PRsilKVoap4dke7C+Hqu|3d#Nx2j4 zSC1m5o}|8QZn?Vt97%P?w&smHv@6Q6jfO3sKlR|khyGOgh3cv1>zJ<2^UxHOeW7c5 zZ;vI{>ON#mUOd?`OzzjE(VSAY6;$JXceWlck+wV@$Tc&?`!-K3JhI`Y>Wyt#`o6BV z=-;mhIf5z89_W1`@8SPj|{Xu#R^DP+nZtjvn=Pw!BlFHYQ2n?)yz_yn*^MaPRaSZ;r9J%xwH` z$40#e2oWizx3fTnJu}vtd15|oHC-_J#}NJJmp~%xj~3<^DvTn8<;f0CK0$V#jd2T| zUT$Vew?IQ?6E^@K;rOX*ie&aHRPx);*t?7kWPL$Qv}Va=ior^S$>5-;+e1Yg^fBF+$mKko^T<0}8KSiXHTkq~@YD^y-3)Xi;pStA@_Ow$_eVRQAw zIlny?t0wy~s%qM)ZrEf5zM`?m96O{7GH{-U2@)gX}HN<^e) z{{m5=Jhzs@yg6hEGTTWIojJirN;wqROz zsUUo68S=Xr$x@BO-rU%jelJbB?q0Y?cC&}Vd~~9uP51!#M^Xp9@G2buEZD zVQGl&iXdnN3a|0~ADt!4@Wnc6;0J7O&HyLQJV*guhKmJjfj}8wUYHsrQBJeV-0=Zz zLF{HychYX0{3Fny&@o?w4R{csd$!EU>7U_o{MBjk4oH0@xBq3N7Nx<1hvSiuQO10} zdS?KZ4bMJ0%SQd*A?^=A3O>`dIpNGVS94^qibV&3y&uoBV^Ny|x>%Lw@z-v}61-7S z!IjUAAb;^bOq2;v)TsM|s&oVm9i)d|LQM#KZ}vU=Z1=tA-fs+k#E1^UTI-#2 zKJzIdiPrlYm>U|oL;Bd3H6KDrw|K_SE57FA$4w)0Ga{FMJ`oqmUnZwSk;^L+N>i%S zKNecAn|M!EYoQ0hV9%ArfjDf|0vD)b&us z*+)|WDAy2y!%$%NThVmRNkUmj3+#2OO2r0pBb9^-i2x260a`{bKB|>k^GYw+@U`aG z$e^QsdT>hH#iJzPw_RR7akv)u3b$Re(pXs;xda$GceQ91!8uQCWRK-G?kJ_6W%B1|n z=Z_hm^*IusLwGnzW?iwUJVug?S798^SD0IHYLD1 zk;`n&@6u6TIC=a=zN2qRY_vaQ=*&WSam(Oh>b0(>zM})tb|NIx@9+$HYxb@h?3SS{ zkBAW7(XPyI)5IE5x0hjW^SLJ5sO-um-I&Y9Jq6_hMnlmn&3*dzg(dijkH91MFZlOn zMiecZi2kqlAaF|74xk>+KqTd!Uh~QkRd}qrcN*8ZbFpzJ;4Rjg>tN$j6-zrd%{QF;NZb>Y|H>7=S^4j}z|=@XTRqYP z|JfNxsg^;Ya^$j0*-t%JAi^pi0z8N;3PJ)HR}C!7;&!#%zEPd*2b&Ko1WAZW>;aYL z%O;VKH87n;r4w%WY}oVxF7Y3lnH>MR#JX#={(w|b>O*pCbW&jeo>YHBo`1dmKue?W z>oUbQIH9O6+PGdSd6|W<)?Fp5_FvO_asK3>Ogzic#Dn7>NUQO!AL>l$!y`|b3umd%Of zD^0DCFZvSWzAQ0rzv38^gY}~%nIrtqkkmZZY<(>~?cWq%T;Ibg9oO!5wNFtMNCfqq zO?c{~|C!~!u}7H{fg{!8LbYm_@UyFAJ~uD|2hSN_$z?(_W+`D?mgcT7tpj--Pdk`UKW1QI(3s@ z^?eJd!8}Xmm%J+qhN5KGAN|~w1oN|BmdIaM@i}cY5UFp2GJ`wj`Cjy2?b<(2Y5nXTPIP8Lws2}?hUWCg=@^o7+!{MQeIeoZod+n-A5vkO$(KG**odx7Q_ zv;KVIIxWoq#aeQZduHOhudxH1_-ISJ9Vo{Cq>g!B;JQ7)XT+c5Q&$C-Ke~&#iU%%+ zRCa~*Tj*F6DXv@?1Lm$G`)LXvmzJ)th!%bp&@J9`;mvPdaR1rF=K zWj1+#Pv358G`0e)@A+7;$83Sm#Zv{0sAe!Gjt$I5V|bMZ?gwq+xJ9v=_%7jAc7Fft zG=-!UQ4Ky;(8+_8^Mmsv%V4oHzoYn&v0~z-aO{0;U?7cRO&`nEd@&^iGrk360aCJN zb-@G=s;^@C?#y1G0rYYD=X+Vv3r3{SIC<9rSAAPF$`xN{4aR#pXt1nj<=bF0< zRSQF*hZ3$a@36ld=(f(dqtvY83xIcBI~pnGSKHk>LB(R!6JLU+k#aVpNhW{E&49+l zd^SeaDKy<6P{p<9nBQLIDGV2*r)2H+hb$tUAUH*&yN9}xXO2;rGpiEey zi*5W|y-X+zx)Y;9vZq?720LF#U!8O8*1}u~V8Y%mXelp2n3GE+OMREx6|U_cGPY^D zBFa?g>W-RZ*^0~?lQZCSSqsxgCoIC#>bAK?&$?fBlX&n)8STy=MQy1M7R$a5Mmw06 zz}{ZfbuGfveE9v$H{GSL2Hn_q)SfIAV+V9^53O!smnjPl*KOPZ8RtEj^6P#Z{R^sT zruwO&`1wzL2VUTl4{QOPV82GEFrPvVb7m&8nn&fqaV)iXuf~C zp7v(+xLQcIwh}KYIg+~#x2w^5D-d0;kWeC8*ENdeUtV*YzW4&D|euErnNVAiF^99@Y68#G~JrOm_X%U^=ORJ~#?V|9_hwrIoE zI6GR-jc!<##2WjLxFQO+l%fPg(vMA#Xrfi@)u7E*>9p6O~2if8q>x%xs+ns#fG+QE%9#*cl)!)+Z==*aV75e zlIFT#_>G=@cX13#CYjxua@jBYrB=XFt*5WAq;H; z)$tPvtni{+WXgDldqIP^0CJ;R-@DdaMlJlzO(M_f1e}|D{n3yve&L3R`_G5>XsKBI zNTm+!D8c-buc^1W7Q#2-gqNCf`a{WS;s2D#V|!&@;5PrCX(EEO}L44-s6lsJnVEt6MX~Z@`)&xDb7t z%+-DN#L>K-T0Z^lv%1iulM#}OLFauZnvtWv7B+$A*W8tJTM5f$q>JaYPmp4Ko{UI@7RQ-9|{T3jZViiWdi zT32gs-}t+4BzcME=ela{tY6$a)aH#xM+6T$d(+$fCi^ITeMqP6Wy?DMh4pt?Wq3nj z@eRPF8RrKSK!GA-S#Z(;JP^Hx3Rtu_9q0HL7U5C?|c$Y3(mR& zHq2|(HQPUU==pgPNxW6nYNlTx1H&x>#7BpB>+YYfdR7g@ZObhJySep`&E9T+-Ka|x zpX1M;KlxlYfV@KMb&Ba{&@RqInW>l?)|sZeG9RnmvhHX-NVqsg!~>q!rAy!b4~MS* zI`A{j0*X8NDq$tVFI9+@g-?{0M|rbZca#BI-k6t4cji`Lcxbiq_(?*_UFa0V>R+Rr zJI#na^7$Z|?k1hzi{l2G%AG$bcUSvaDT#!Jn>_fM;Ph{V39a1v%^`q&JAvo&TR54d zxZx)l_kEG~=7am$`F@<+x&&ceC?e|gIR5~oQmQn0VH7VtuzjKS(MWywN5m;zvwc66_UP`~Y2!zsE4JI_(YL14?tGVj z5r~Au+uf)=`0MWd^K;$^{K6n!{p&;KJ3gjR|Byk=Pi8EWz=~l*P4m+y4ed2SU+yM^R!tOE zV5V-zDw-tAsO^_$=k?jCTWHCnxWq-8bzrV?$FpTS3amMAlCOOI#!9z>gW$~RgTto* zv}0(BFd>4}plC)BPjf4`KYmLK{jD7TU86Iw^sCS*6P5Dd{Zp;JBCs&^RK|>PNC6NI zr+bFemEI2A&RmZP1cahzwTyqR!!Oc|RCPZod&qp1W^a{zDEvm!#VR=0HLpsZxDPhs z@vCMOy}Fv~-ml%&iC)93{dCQkRnzq!DuOYb1=1-mPOWVyj*fqWKvCNryQY#9rY3uBmEs2}kxlYS%YPYGv=S{#G)}zk zspERbTX;-q2brj^*;o7C#tO|RsmvvRy?ZSqIAggYKnT6&aiz@26 z!O20vrj=7aytfBSuBm@YKU5%GTZJ-_>>y+MZk1$yQ{Sl+a!DTk` zTPJaII>kEOxe&KsNuGa%n@0^j#Le;VE6O~g#$_{tgrLV+f1&E{aJI0&ya4_J;{P#e z|M_P2g)d~0U5lTHj7Q2&AplW%V|4g*yw2N4AlqjDRE~TmYsMO^iH-FZ6#UzVT1pU( zhsfR1o#_i-g5QU|di`}WfGLEO^Ldw^2Qc6u(2tqjGvp!|XN>qao{tMD&MB;>lUd~5 zuWc(8SEI+a7M?~mZ!JndN_OqDqRiJQ(ncdI)@dHN7?~4spPqg;xwMErHgdF8{=6vk zQ-=zqgzR1-EpH{fnrS2%ny}yfU3WCUaUn#Z>Qk(N{?aMXH$DpCN2%J@$r!v_yNc5{ z2%A!^Zl6CU)@I>P8R%+38hr9%h2i$)&N3#cI_;ejkHZ>Q zXRjUTLnt;mF}rI%r$V=$g~Ic+OmeeQBg?RjnTnWkjbjY$!5M@LR6hP+UxK1RC->vn zG?XmNTL|Cx3YXT~_)!iMAWm%g3CJXVV;U6|6`f<32E?S-w+tQLQ0MZzlFmjPt4w&RbCzfpUJ=V5t0}de)sfTwAa~1s8YW4`)h=j!b{Elg^o^~<3TP>oH+@0ddT z-*mAL`enkTOHnZcbM<}a;U$iRI(VTCsj-GYalAD`xI;StPuc8gHZSRfPFk!X#1iq= z5(cpekb3kH;|_B;!5-rs<&f7=a=6ml=l!ni@o#!6Rp80hT6T9o113{6@OAJuCz-_3&1uhUQkY1yA66iUcORUyX9#??C-4))T z8-0e}`BVg%^|XNYo6p1aFE9 zv!(tXzmn54|Nkmu&NP}LuU;rU;`>{iY%>1h;U&-^U8rnw?I*8ie!hV&^}Z_(u=R>69N0y?AOu z;UC(4A2E1+_^EQh^Zs+Mzjp1q8iGY|+MODjF(QEWi#=`!SlR`5n4sbxCM{Nfef{3z zb>fL=8x?Nu$@Ux7E?5o5k$ij$bR13i%68K0%Yc(Q%-TS~J25;#Asnk%C(t4hE^#Kk z^7EKK>8JL$TZrF&w7-tNzc1WjX2HvcN85a_#J9XiQpy2Yg8=r=W&QQ_W|y*n^1(6S z3ur5Uc;@5Xn;A27V68tR_EF{sBMt1Ukmd1T`%DLdG#V#Qu)^{XmU3yZXru#5bmP>J ztwisl%qR)LV{a5-I7i;yG~erJZZfd7H-#;f-p_NS=*eti{iFi|GeEsB_8fJ>I37F5B9TWPIMQLnp+a))U6gj=FCevO| zgkv&9KEG2!t5d7qPo6C3e4ros7?s95aORfD?^Lf=mB1Dms`=fMMX;a6Ooj6SkFDs* z7CR}c_HU>dwO7_@?t1ApKSHT6Ap$$_-ok8QwJpV;LgG_{X`X>k;2Gr?f60t`O~!U) zDj@`PqxyF1nP8EQ@r8xai4Pw|DJFfZ0F(jujX!R<)dEZ8+p`*z%EI-it&$d?i3Ok= ztpxDf@z#})q>R`tun>OV=P;%l#S-+ltgZR9{xjZ84Jr3!-0MLJ5zE%AbC(-MB~aoT zpTY<7J7RN=Nwzfn@XLD_6FyMaLoz6iF^6j9Cwk{CCC%7pZyJacMAb>oOcpB2@N+(% zVn?-kHjBP!sn(ntJjX4d>>XxVrpWmVnYUZmot0^?B!r>JY+gfZO)j}wjSQZjjxqCF zi0H6Ho@ZakNvo?}4c35t6mza)M-8c$gkdQDxdKodh0qiprFFCA%QK>XxW{t^_UkAe z@$Lxk=Nb3~rN|%*$_dg}53WHLtluOz%&6=m%Ia&!K;&uM$`x8LRC-$9abKRw&w7>F}$fI-ckH;d(h(Gl*xUj=KCVi zV>9y{&3HI+rmJUWjoh9%8}?Ad*5*SS%;#D9?@5-)S4bE&!v-a{7^hIhAphtdmON0c~dJk?j@O#(&A79^y{^Fc$$Gf3GI78sl z4X1NEOXK%!<_K`Qj`2*;N-{%hDh!`*-HtobdfMdTt&Cp!29~=wkoOG1opkN6#nA9C z$<3Gx?7Y?TO|kNOJv!^FN$OSN=i%yAjjPBVMZyrJayKQvEOviG!g2_2LEJ$u<^(&V z@+zGDP>t_8MpYx#M4*&BwEIg)R!i(bnfJ*n-9sc(o& zz;|Ku!~H(^#kSXX+a>(Otkz(1xr@23TJ8JqQF=Q)+O5Fl^vgqx1>j`@aHSHZ*}LDD zaWf*0gB-Bv#_}G>wQ&fG_`!p6G)6>lva=iXHnJ$2?iuBdp8D2;G?_X})uUAc=AY32DpC0RxKIjX-8o@Z%|h}8 zi`yQUB^wn!R;GnE7UId?s#v8)FzU+N4U z6wG6n3TO|*W_I7x-Ty*#vXO4F)?eiAhq5e64rPw!az|`mA6$JUyCh-s?Z@`og4wV> zdiTtIwsTJQ=H3DuLWJf<+I(Gf6h;h+^|ut7V9*W~{s=cE3!tRwgM5gM}zs5y|;H zB!eM!5|WY9aO}hHn>InkAgZ&)i1YFNDt6}drl$M!Z5NLmJNcA2vt}S$A?d&81r`T> zMd>$bD|v!Sr^+jD9N^Tr>-i9!R7L>RnrL(WXS&HL_x8ag>%>yl(@6qW){)aK!MMF? zapL&Jg!wVS!?;JYDhae;-BvHJo;1Xt$c(s+77y*5{r% zQHi)3v|cdzm9IFT{4#tc33znuzD<4v1$;?p=0~Rp4y;N#;Z_*=_{pFxL zukBqRO?gMB?~5!J@bbUV?HSannKME7r5>M5Wm=HYA*_&##2v`{i^R#`rS2pIQHQuW zOGG-nf%F!l#3>@7n`A@;T*kh)&moW4nWwd2OtFnBSbK2p(_OE+F&c+ins`~Iw(jTv5s}#2$67bj7W0(6|tgYG{o6FBG)I4RcQrzM0v87ZI|L1wnJ}|c z?XJ~!t_kIekBYgl>Q7OchdQR?bs-Ni$NAN%nTiiFc&wPDD1B+Z^Npc^KP$k^bPF%A zqb!?K+@h|69mCY3W25<230XT*N-5bavo>9+gHrGFZ6MxL%z$d@r)RQ8ZR#h|rvIB5 z@1Hlx(q%vh^gqIVio&^JAjnt+xE&8!^&@}cj!(13i-&aHS-*HfO`h&#Lo#=!wd$Qx zcy%cF4i;S+^M_AlL@PCEz2?usbY+7i55G1K=hn*K^C0xbE`N_b#9C6u`{G*T2GXHG zUk%2ZOUBeeu@T`2O#9fxnJZCIF#H}|W`Wpdj-Mfybb6C5=OxLXHOrA{_B+-D-El-g zz&RE{fy+U$2NH8*p_}JixmYIeps($lp&PRT1~~%6a);!wi1Qj_j&q~W3y)adT-?)MrKCI-YF?r>Z4$ttQGOrTMnL2^6+?$ z=yEwsk6Z(($6=qx4`P%whGrWB#|c}PIcBLZAEp=TZ-yf3*6pgm#=?*?(H<|$;FJAM zTblSd1pkE@NQA1>?|d31H6Y`-n#t>|es&7*d!B-wzbg=~`+iip5>5BuR8VMW=qG*q zvb++-t6W^WCky72fU_L66>L%j+rjIo6yUHk86FCZt&E}-^S}0r3Fyoe*1A}NO@lkt zVbe9rE+OFyNKG83{;9b0>#wXk#J<|;gb4B4&(*9KeD1V=k*h1sZ4x9kiitejnhU*{n#z)NWzHm2r9_o8?+kzs&lPNwl z_5uLa-w z{eJn6ppQ}`>n}wgYn9#7uQeID)C2ewt(T4nQ)mhe_fbXWPgAN&HestzQ#UhbRdZCS zaDHsT9VPk~#w)4z%^~!w&w(Gc|7-WMU?z<`j7*h*Y)7R)$O_zp$Ux3RWbc;i4f>qe zEt9t55Jo60y&vbWA0qXbC!6S#oGBQqu0&41SJpcg%%evem^STL`L)&+`?nr0lHmKb zQSK&tSQA-vatBF&(0v1Oo|S7Bv~e)}JX_zy)z@xE7xIc-`0|oqcbAWqA)Oig(-1G0 z%0E8b>+Apg%DKJS>cwWrf)qsfdZN3%k*!3%e+e1hZdEH|Et0rmzp3I6X#0MKoLp^X zjf=JtDpgCOrR8PZ1wn{0HtgiuLhA7RV@fX=j?)7L|AFMO1{pb4G(MbAvr<4TM2Igw z;<6m9u6wC;l;y*_hT4)oAVc4rz+AgM zp3K28Ccq{o(}ZVV&?q@8)#T@(RUJK~lq*61dSR5il~>*5^Bud37z#?KU#2QyQHLmm z*M=EA8stEUi!4=s=ZQWwG_H>uVKa#?*<@pVj?q#*DD`F-fFsTP#+nP+Q(+qnL;L;s z`SJdfbQg|d3Nj1Ci0_OlHz@yYnVvzRy|Vf4E1=6X09xW6ISF()Y(ZdTg}H4}Z5N)N zwsn1TUa-p%5)2RW0nF<$;%lf}dj z#TUAR?G}$@CH0`G3c(aNT9Z9!Kx7359V?@A-)Z?#1Sc1K&0GTUI4=gPLcvrxL^c|6 zC9r1fnFbsgq|3J$k2}psOX9|C){d$~nGL2Z_RW0i>TDuot(qMg%~3IB109u(t4mgG zp@RDQ!}_*IM6r-0cPOXT$@uU;H1`pKde2WP@gHzh6R=;*DT>ZZO&5p5b~z*TabvUO zUPnX(WKiS@NBv+e3@IAjC=DBPlmVk@>(lc{Ka5=I07qi&NMTM?nfIPtZJ|lIcl%7U zP6Qn8XJV8CQeY`^GBKOGOQn^KSP8SCrVv-7A@o>Nu_o<67@@1Ww?BD&cA(0jV|t-Io0O zg#4fItJ6WB_=@=T{1xMMF764%Z`S|-ONw5ke#-CC!dT|4%4EQYn_=%R93Bm8t9Qdb z+dtQ;YDd)@836J64-IA73EG*jelRz7LT9(iooPvWUh&-{SQT@4dvj9>!aeZ}iDd3E zHq(cw9@ty+A0g$R>;n>9QOCSeX^BusbK#I&Q^^etE)8G7eJ@-LJlV#^Sz85p!%b}C zzD@RtG1oBD?~i_Y6(^Lm&GHBhM}(W?h|Z|WQzBpgw3VfAHW(rqq;{Zt%~R2hGU%eQ z7&u%%6_+q$jYyP%k)xm29+`H}3uPU8HulZnW`22A*rLMaZMN^E#iR12?F0n9$qfaz}G{m^%^E z0}bn64y6;w8dg02AQO>p>aF1B<$)Z2z9ejKCl)DiDpRL_3%QqEYm%34RZ>x}?=Sx* z4xvtfW8X;)Ik_ipk&2uh$TGQPc%CCXuOn9G<)*=aMaO~R2iTjTL7A{yB_1M)n<2Ge zJOe{6{jB*=!**Ham=3JONYTM4FR?<>Q5z1QJjjGcyMOv`gRQ zv>vr3W?cDW1em6NhjIcL06kTvU*PKrJ#ys{;L^UbCj{yl(wN`j7xerhLwwI65-s}mQ_>~ zsOuYNK6kw0vu61CC(AF&TZIo3&IHG9Agxj6Lx8Z5f#WJ&L7l-@c25H;>*>p1oY+9; zrS#@Y@v*+u^DILJw`JoLlfAyhs*fKpAJdLBK>e)m*%Q6pZ{i@YsU03wa`Ox>hRAk~ zE1DiTz}Uw_+98I3jadR5WWLenUY+;`E>$#cRnPH5HD$!&1av$! zX_+jC<)E~nI{*G0|8K9CE`1?4jRo@@AK@VhQXl6UiO8_o)tMNyDwV@{fkp@b6Uj8U zt(d>Dc(DrHHI5(|b*r>oax150Nz}_3X;{=ttVIB68pVK$^Bqv%-h_}-ju+C zqqt+HY)2*(X$2qQEj3UOp0kFUIpDe`4CZ1oPc=WVn#WYe>CQ6!k-(2E%HUq>9i1Bp z8F&(PWE-xIvc=a_#2~md0=T-iUEnI{F$%=vGB;y$=^ns>m@n__<@ix;BG;oVOBMFzAabeVRC0VRrb)!e0dvdk z4i#Md@no(EBnOOAT?4$^-rR7vn{SB*yx4Z438L)h@NyNPvHgEb`JeUOMk?qQ$Jd(` z8 zlU^f|MN@d^Hb@i+0NsI7*47iVG*s_{@t2XMoqlfXN#C4liUNMRoo-& zp$?Z1PFp{$to(U((#)ng23f?+ZQ>X#gkxAbjxcm;JLdp{_H{SQl}Flr`rgGJ@VMXF zzcyyT99#wGybcMgk~M!d7*QHlVqm+EDJzPB{fG(%Dq{M4Ie_yY0LEjlOqF*;38i@I z35Al<;p#LqHKgwp#Y#r5&8{uwh1KF$Vk97geiBtq@R2?Jp60(m5*=?N7xBM(HEyf_toN*C}Q2jc2&=X&si6Pc-WKiGoQ z@A?}78)Fnumhkk0f%fxw{qFXc!NgMDZa}noFx|9^YVH?$(UvIc3Y-C3hkA!ATt`Z6 zd)F7k&bz!=7_IS5{||A2|EYl!R5Fzy?H?LBO%f&~>8cIUucjM8JGNv5558?nQ+@9; z?$VGnJYQ!_;j`)Yj#r_l=I5I(e4RwyB^Fif?i{u-cjph$S6nQ?Ce)&TBC5+elCqtM zaXNmGTvTB~=E$H35`tSMm+HktNf{Y(b{cC|1v0suMircbT--muAx-dOBv|Yon}Hpz zwDjO^%IYrmLz|nh?sGJ44Q=_XAu@Faed;7ann2MUBEi?-`x}!S{M+2LNLG@kO*S3t zr90zRlWDpI6QZqS5kK?ap-jrnpeTLZ?%8=FJQ>#ZRI^`fij^4EC<7CQLnVwb`xuNZ z7H{4};uN-$MJ3ciysZ7I@&&9)pu(yUFc$mkk@^zd)h8l+d3OD+WejmjG+P>YK@w11;W$m)z#$3@Ke~HSS6>bB^l4J zN7*0n9ZA1N&D)qY7UlN_>hrbh9{hZg28!`uyA;CzXR*%sM0d4fA0))I8xg_rF&lkj z{)2+=BPpt{>@uKDejD1NN>=rZ-G3@no&48kbhm~BGlqOs_8Ay_)hp*T|f-Z7+$ZY6;n7)@iQqRCnE$DYMwpQHeJi$h!H0{vJ8J`*>e%Hf2 zit8)qTB58WDx%>RD<~m`fL+wPCn82sFyGNRwY;|bg3&C;?ks6`#_7tbNfXZqG{ zheS+uK-Tct`)u{nVdPcg`h|Arpr3E%nnb{$rXiQTf3{dX2+Mma<_}%_$P%V18>IK_ z>aLeG;d*zFuEy=#WyUPa6~@Y!o~vM)liu{{^(+^4NnH+hKr>6Goo}u$gT$eZlBD`C!M(dgjcq^4^vAm$PX1C( zAAw@I$QS5|L8Mrmwe0XyRj|H5&F*}>NUbD4ZwCrgQ;UUnD_WMXb4{5~0;<6I@7mtT z^6S88cQtjNLKdK3+za>ybd1lA$78pw8_lg>p?|~3{|g0EN;vIxVqr>=5Te!OecxSdWkpkRwdHe3FFEA!t~jg78wf|?Ij#t6-*xg{Wyg54 zgpqzzlYt+piPXUpadyzW)_*TKz(LdAANwdGO7--@Xwc|0+6D%ovmzU-s@B7pE$$g! z_=lH_`kla>oIhLcKn3nxsc8_C@l-f3@Id_*uc)o$#FaR@AGAThF+!{|h8p2?&sQj_ z#Hz!ENpVB>$=y)}pKllU$fipSI^GH(nd>cA4bZD(ZT)c$0@Q`Un;<847zkYi~DE83GH0%V4cH=SO0%4s8 z`usGQAuFkbHuu6Bk5L&KPK7FWdZ?>dU6E&Iqk{4mC?Wr(^|h)5TT0^7Sb&#x#2`vS z;O2;EB8B49j>dKiIFKjnpqTJ#= zcJ?xh-JdNKl}TFqxgvZm6zPxa`Wo}ya3n@~tp7$G$2bRWY=yYc*dP;-SUV9`Q+cn* zeQU(KrYJNjQ$R&%{~St0%LD4WHaCbsJ2Gss>^v<_fBoD~p$s#;jrcYcW}CRJrI6-f=?U6cV+ z(a5b7c@X0n_moPap7Ni1eJ641+D-mfk`d_?{wK>x4WA2fZT=p;PhRmu57(hq3W@BV_l*kmOncH3v7RU=vq0#Updiev|w zlnS3Y(uwwcm7)^`Box&^gBmKQ>@B?rim|UMGfeq4N5bV@&)zAwS?P9kHXoybZRZTD zy%9QL1EXIHgNHijqb>8gVnFAwh=&a3b?n6VD`+WJAZyVkF}tZzt^*1KJ|mBu>-z+G z2?`kCA!a+%uRLwq?n>)s?Npmsb1nY|UW2`0z{^|pgrpR%UvA4_Pd}i|F!k-uQZlTj z-O;l5+g>4CgukAwcz}Z;I;t(YW%YKMa*F22N4o86k(#A;rGeb~>#6Hxf4X@4oTvFA zTT^7dBY)I3>_x_`(INAe!Z!L;sv1p4NOjI^V=LB*rBb2cyTu6C;Oxx-js@!>h!aJk z3IGKC{@gc?+pXnoAPr>AaB8Vc0? zc4;(#(ibp2U_VfVH?*&y@)ActPO zy@|u|Lib%35r~sK3jKe!E+9JRx+>axhLajpT0xyGXRqCKomfBsJ2L!(|FP7(N2730 zQ43fr2=#S$|EhHFtr@qCwD{d=(1!i+Xt2i(w~HG_-FqM~in>D(6h1sE;B#t25_Fqo z9H75A%p>Eaw zw_H)3iei`?)W>ZgV=ugxtA#Dd$S&-AVIyASoBBidy!SrWDn?Fk4B3$&==>wD>R8Iqj>2tHJ>T&?JIZ<>nzWSl07JThl8cB4P9n?4&Q}*qVKjJ2wa67 zC9H&-1d7;hEzpx=-&O5K;AHoUgUt^zU*tz%F1%=X?(}-8`5bLP%Ywmi4Vl}L0!tg# z)hGAPmALletNFK-h1^$_YXmOUm*nIwyq>#P9&a1!D1_uVvNg5v$KKj%$-_s+?q8`G zK}AQaz)?$koFE3Eh{dP6)AYzV%)PUZ!I`m^WqA7$!hz@cu3+@aE|=E&xE47A8hf z3nRCV!bu%dOUXZNHXfs?o1rJ_mvQ8gm0_xpnoSX_j)H`pQ|@a4&QSUnkze};fqTku zN!R+WGJwd2KRch``)$y^K+~pdD%0f8?!&NQ-OUV$ zR4r$xQ*_&<3W2YqSl=haa@ zpZYMMOY;Ke9V-yaOP3k4-0PQtEU1RJSNO?ODNZs7|499jrtWg-wdUSFbl6f6tb?ty z#hI;b-b`e{y8VxqPjn4g+{q`sR6jn$vRDgmNU23POAnvLz+!sC(GcWsyA z(z3hvDIxu&J<3qOKBSpMX?o!i!5m$JU8%2Z#P|UEoH*N9ImXc=fM<+{6o%k(vIH<2 zib!0p3Cr|j+Tum6CzVZM70P8E8C`0$!#oxB$BgnD#p&Q#8+)c+)w3PEvsS}GX2ffE z-e%vI{vD0h7WiV3-59a!5c!x>?rFvlI4NAC(tgYXxg8g9VyN0O^!2#Yrs5?z;p9)` zQXpRPCfw2pv%935MXP6EnJ^_CF!g%xp;kfro9n$xz>H^^>fa3#4qKKS?nLBd0sr|H zN88@}RxX!Tr>dwT?`HhNb70??Ex-BWzVRZjdit3P&?E8XU?&bMw1vc$}W0|Mg#iM}}Vt7Z9Kad&D=URK;*q)Yf8FqFC*$q{Kh$f&aXt@|ig# z%!7RinPG1obMvvO3!}#6-nYiT{T}bGdxA#Oukr}#{Dv37w%h;D8_Skn-+cH!ov#Fs zn8Hye_9wwUUT8;K>--TLVRL%{Rh>Y_=j+{E)LSrxuAg4X@uCzj-ZZjZ%e&^AcM|FFgyh155*2XH5v(JJy2$6ubs9Ok)&v-NP^Pq!_mSXQAJhDvbz*GwW(P9-&Dbd<<8TQ-`% zZaC^t0)5cam3ji{YtUEE`UubrRQ%;)#cMd97OYKf8JJH%RQ^E`lE9llYlA_da?jR$TC5+@u z?mO-GU40L!$8#jI@yXmF*spbTV)SO2I8-wNg4gGcO~-4Tej{*UuvciW!U2NS+cFqw zeUdo;b)P78bM0xX`c-oRpVO$R83Puq4Yc;=7=Dp_$$vcH>Ap?21`zx>plV(*JXKzr;3H*p4*;RR_}8goHDeE#;T}(r*Bdfg zvnG79%<%MQkUeddB>DV>=$t0Wvtc+Jbbsi z-Xr)BeFU_B)hp#wlx$0Zs0m#mj!XG-JO~0c)qc}jLv;=|0h$MaLgE|2wRNaDt}9;dPZ( z6YAG}Nq>jp(C}1n*3Hr>SdQ2uM#Wm;MzVm1J;w)jHS``1iiN`}so7=a)xTz@9 zTYl!-E#o(`Sn_pv1~JM9&F(QzNxreMk;CX;d#`JBNbWsC=>D&`OPw;_O%?@eWkdMH zRW9t@bY8RkvHOd~_()`h6Uns`iqDzZutcx7^z3rL-otzfwx-}(dDJ`1N`{X+VZ8)m^hOtUn_L+y`=RZQ9QWFR873!2Dx8cashR(n&FcU)0pD zBCy`|*z`~@=*!VT7lHRfK^lDFw-rD^Jq3tu41zxQHCUAne-9T(PnN;oC8u=x@jGA# ztfX5DN`V2TNOh1a5MDBV-n{y=DT<4I3kcr23r%o!QGV3ff#H@ta7s8KxK9nrs2g&^ zaZ`17ROSbN1m|QmbwI6e5kQ~MmA__UdDgF1&$<3ojKQg~@~1OCHh>TBJpNv-+SUWa zuC$1bAh$;jKU>iOb=h(U1o!9*zHfpDnlM%akGsR=+}a=`O#w`@3gE_%DSvU1-u#T#*cdNh8?$m!F^}PH}%Ay3;W(1 zPRzl4J_bv6eIpRPzc|T(7H$)WUUP*^x|*FtIgK(R6}D^BIA>ehkR@G%OebiPu3c}W zg!Rg?`JsK8SQUN z&K~vi-7J-6Gqq}P;hleW^~0r1re6twK=!k@N8E#-IPay8bejgT$j6`CQ)9XKu6!9J zWt{@>r5XObw)?%)KzCP@vlEk>P&@uD2m>%gUA~jbjOO<-cGDo>nD2I+7Pq2fq%3&5 zh~=~EJ7D=GCrD3wXHxC%1KuT>+;Hw-FTSv0@P=S=4YyaP-IZT&l=PVz|5dE&E$n#e zTV6PyK%RW_k4+R`ymsw{ZGwbqN9|k76Oiq zmXXRE1$h|~(zdNHGyQB(=L9I5hi--@EZP^w0R=4hvp~m-0quYP`0d%=Aq?khf+EVyg+M>qj#y8Ved>hX#&p%?j$Jd zrpS}8=1t2UY?rSlc7~)*d7i#})PEkA7zDS+DwLYHxqaB5Vp~?6A-GAb?9F;rG``ml zaR2W+9&}ml_o#R^63&u0O5W*bH@Z*_=uX=%kg{7*$6g`;oz8bj zvaE>1W|`3c2A2NHll+ZU(x0ky@GWne7K`rEfL_-jzmS1O=M1{v`1eiIL|ba~O6aVm zlh5JurCN)`Wg{~}m{JDQXRWOS|F2w>Wfu>Byik!)cyPZUQnWZ7y`nyFz`hYq8z6(< zO}31b;guvEA~?}Y4u~U832FRXVvtmux%x~{+g>e0D?!&K&os}BHMhs^m3P(b0pG?l z@lJxYQ(>ajko30-H`eX`^7u%dk1{x_2Mia{*}FzZujq!{R|{M*D(6d##cCcGerh%H zSR$Q3pRg+%65W`23yX#cP9}8iiJ?f52Qs0emf2Iu-ZG>eo92ZJ$9Mw;T64pRU^S`Ey-`)P3nlBr2PRjxfHq)_3P3YuIXso)Cg@duw^b_YmPp9QEOq za*(HOsNNy&7LeAabfLv8=Zi0HwBPtxWS-t}OA-Dt3$kl~$3s)ayE853nMhFURGC!C zwyDiv7j>JRPqLBHJA9tKCcmANFK=pLAiQ+i6?L#^jRCMuN^wunxw4C?p6rK*ebpo2 zCM9Er6!iwSm)g5d2~|wLnE)wiXF%Syy39tqGUod$rccjn5sBJ-_WK}fhY_Gj+Of&d z#!KNsy5)?kNn`s~3D=&4NlOm1o#;fu4!vZ+w1ok&PVCN8=8{H7hX?{#k+6_SCQ zW81PN#4llLOrd{rf1~75eZ^;z?bG?q3xd$;rBEo?!^7SeATcTE0Bu0RxyelnWNrdm zRWCj>8=;EondCG?82T*JAsgj+{UyAmn+<(_8QpMZl0(v@z^kmK5%)h9qjU?q=vd^2q~tJq zexOlpwVv3=$xd!FQ`W`*Qv{Pj2QS_AK2`)t+cdxAhYYYfZq`l^` z&`sF{sEK~OVJj`@4$lbYe!i*HF#$a39L^jQCpqlT`fM)zINH6x%U80S!M6drMd906 zkXZhOu*ccvHu>u6d2+^fFm@cI+Y1e^Z-X}Ugu+P>?n38vX4Q+1Ln_z#D^D0bPkFE> zXgfui)v~ni8w-b>DWW;6ll<`)4)wPJ^*0irsqyG}%92pD3-6xDU%j+Y`*%OTg5|6d zBXN|se8)M6W^&rdm-4{=)fUR&;htp4&6IOsY)FBM4c^BvCRoTSK%ChElw-`uBO5`>c>9`w4cwDUdGHR_>jSNn%{5ui37+>gd5W`U<}Y2v#chb=D52 z_dap^yR2eR?zRFn;t68I=H<)|ZP*F3IlKp_^?={qZPzxLe7#1*1rAcnlG~2UB1#f{ zJ;WNnn_W42wQa@W<)-JLu57wsiPe1#enleO;;v{?vh}qr{Q&#_@L$#zrc@54ryJYj zhNe9;SEdl93U(?#{n=@(nl_UCUP>*X1M9%9I-Rq;*w8;Ulce&rkI6l%4G6?i`d9PxL-|pF?8IKfUEU0h^O8EBAe0P4_np&QhIa?vcy^{x z=?CP>g zA5pd^PE6CPzk#y-5jbm^&_@DA9Fb{}5DMp{uEG)Uqw$s-8zkB649INvPO9ft%MHlF zl1#HENUI9Ose>wF7moElP7UohGE%$3_nJ|^9ixjn7^YGoULsv;vlmVJWFgsrN z?^2saIHj;!d)yqb_6&SCTe?6n*Al@-TXmL?4&=4nR37&pYJzFzOMDY91GwqMG|E2}8A0wB6|`6%`F7aixnO{y z3FaoJUab8n$`Vq0iF*u_x*jZI_YfTlfhUIUydFY|TU*jMD4}Ku@NqnzGi$vgBtbuX z331Th;I;c*ap`47ezbmlC_UIS4dF{^}ZVB5+Y>_zi)Eq&$*#i{x) z%r&R_tWX>GDnv~sAA(1G=yg|dQp{>9VW+>V?a-1VLRuC@v-uwm3Ai1!(NyFRw|99H z4J~8<cJ1tm~YUxA&i<9;t zW3BK@+}jArfsx0?|9aC`shrgp+IabtOh!1aFMCqz*;7j=jO!Ud#1b9A734sE)kAI` zXZ%PL_&~UM#dmYLM6Y>2u0A88db|O7xvZ2j-2f+RIiQDH(1Xsnf8M+FPTzEHQeiDw zmThGz(|!Y8Y<(zGhUyBfI7mjAs=_vkEj1u>?J^`ww4h9d=;$ogea-F$)_YN1ajM-H z0t?AY_UA-;4^Ji+`@(w@s^_YZeGjhPO}Q!vH5E<0+(y`!i7k?_%UX}^a)BB2Nzdu| zro-UPt$GMUZ_6rN3cD{?-yHjw(C-cfiwb%>q@)IDV^*^-ZV04PB1J^vpQhPR!Ne z&TbgVO0|sK>wk*>=MidZKKhWdjOH!xJ|S(L{$tMVXPH$@sx|WT8}%ti;BM(W%R-sU ze;J{Y<{q_OuLeCDGb6aq8PD}`DMmrf;eLZ!A| z;UfZ5?Ygd}X8Fw-=aB3k@0bg(#2Y2y)dqCE;m|?i_uVS}FTB0xXBym$Z#IX6pZA$Y z_ka97|CyG0+5`j+HZFml$MjBB%s;=VhpOW<@n*|k>e;?X7klw4--G2i(r14pG7`Y{bI zIiv!^>WB=d3fY6nQ7ou%;I(8z#Ud7~QVRGTH&hvFqNHk4aT9%a=bPs%6>&17stTg9 zm+jDo;<2k1jE&NcsUPrt>-&==S&>OnvIDq7qO|4mYI14#A+V#4NmH+3$u{B&P!FyKi}YoRA6r%6Bz(q1)GC|!Crw-e!UOO zk9Rlb%|TOZ-gEB(9pHG3ZrnG6Z+>o*Be?h7cSDoIJ3@eURGyq57+ge7Dy3Kl7Q*1b zscP0NwP1zV*vVCH@qV+)@pnM^s6D{oyE>mt7Js&q6{lH0qC#D12?sT zlWGK;&R?Jn78EKqAo#LPG@pD}!EkDBPLG=X!usx}b6%Ya z%VHIs)ANO+u4gGOlgks`pJ}^P+6Khe@SVg>)+C{?k4~G}U?!75!Jw@hqzT&RoDv!n z;@B`@uq>5f@awn9h+6CJLTBa*H5Fjhj8FiW1*I`hM4WVB504p@MAiCgggh zt_Mc>K2{*!K-Mi`HI^lQ!WF+lX#b_D`NI2io*DJyaB>AcDk0`Szx%Y3Pi>=`^-+ zGF%Iyt>NWhF%e}L9kCP#Uv`@S@(xI% z!0hc%)GB2TqGV|a=&|7y2fLGZ`DnfXT^EMTZZ<@58Lt;0KpN?gB7=6omzC*KQ? z3VcokvT?9=o3*YbQ38a)SByWWtpl6zWQ7Ng$aCEzlf2$DxGCELn;3;ifWema+4NdQ ztQdmtqxE{{UATw<^API=R{?GJeSjJ;dt1+NNjsXzfJI3daOZK%wqk-$%^0Jg@H*h2 zH`+F|0BjpQ-|Dix-Fv)&H7>{NzxyK_S4yXWox76W?b8>d9W+k-MIG$bjSY_c`(KUT7I+S2zHmG*|>?3idVTpj0~=;bDe0t&(Co3qY?@)}@aUfO$2~Qo+nhi?fkBzaCcZazem2lTblk+mu189O{Qyj`O&)2h- z^P|r%ag1%>@&Ytcm~O`S6QJ%<*|p_okJMWjF7H`gEW(K}(^LcAfj^#}5NC$inWm<8 zmodMzO2&Sfx!|-#O~<*ofOel3a+MSaW^s= zr0a;ere+z2NbkHu#l|GC@(y)|oT@=v#%H}Gj^vgWJX(HMLrZ2BxJeqQTV7F59IM;t zcKzSW`3DqNy`frEzH@5;7fFi!8yE&Pb)5AsKzPm9??u~IG4$wqsQV7w9~qd=b~LPV z3!glYwoc6Ls<{4;{or|?XZanxKVw(s(6V?qeDv;@l12n?S8&mW;0tF#z` z=ImQ=%c)Sw9qH(?2w$~koYSF#8a;juwH|zxmGB+LiFFIOA%54|NC28iXkm}o^cHXM z*0p{IIDy2gg7lt)lWYy|v23I8BQc+{Y;cYNxqEz#1O5$2Yy={ETl6_TzHM-1?S0 zibXy}-$xSTa*DAN6b?HYWVNV`|qh5{E5e%M}VUt&-sFb zLu!I~`sMQ(KQ8|4q1`Ed4vXWhJAC&S?ALfo_ka*Ncx-=G+C2WD=V`z(5uP(Kivrg2 z!3(kiEZL>uLOPJR8&CvKl65+m0xUPjY@0(A>HhWOSwIF|pk3?VdFN`dEi2?atSINx zH{x6eOry$@VGITG!`d%R__kCiw6$znR~42|BlRw)>YzKCASFN;DQFM2F{Fq< zzc-cNa3M2SS*c;_j=wilX2tqXY+kv*<3(!$b;NAG%9O% zh4^6$;~diHsRa0B*gbf%~f0^RxmTjKe=XD!HN-od!RyegHNGy zgbFsE^B>3emr4(rYM0D_g7`)Ucg<#=6X2vbuf|^z(*@NU@4c#*xR|Lw< zRY2mNCpQ%zcVx>0MjoGY&X@#n4(A7rZ`OYI%hJmUphcnseo|#G5ZnJLsgkxxk~WJh zw{e!}1*`4#Bz%CNnP#Qt2+>3I-<}8!t|Lb^7cW#Ku5NT`F@Rl)HULxSzNgtE-ApXc z=8oZ&i!jZIg(U#qyaoe=i|3sM9VCa){s)AcYp_GQ`@eHiFKdgWR2e6=G?uS-_@1JB z{`S;$wm^z-p~4?hrPj=42P2O^ogDI)aAH*n(Mn2oD2#7JX?#cmXEyY-Q0K($6JV>% zG>_O&vWSG?(Bpv9`E;sNPWKHHf@HD9ZmEUet~!oFq}nWDFg&zUWF?^hk>Z7JIRP1D zqvr6~2;YlQ?A`GjrtJ%N0KBiBuep$$EBA`HB&nKHLaPbZ%kv=y=5{GR8L~9jJfi|d zaS(Ik`jBg}yAmSH2Nx2$2;;b--Tr>;(2pMk84(s$ltR^*UiM}}IN=>Wp;RBT_o(eW z^1yTsGrFgX_jSTGO~KEGd;3{6OpaWBmb8!iH(KkTL1H1bHuiyO;_wsz&l9`Ve`+sF z{s@><9`@N@Z{C6aLEfL>M>cq}U3+|+P3)19lKFW@`R{qJ_>L)yYY&rmnuytUe_RFH zKJ14aoQoa{4Ffr-zL30ft#TzZoS)+k!~oXxI33&4%Eq~L9|ul_Xs=z)N^*|X0}!^NWEg-1#ON9nfL{b$LR@> z4^2~kgHihA1&@t-uU%)n97ROWYg-OXA@l8!gb`h z43Mv2V*|Yo@J6~Dz&Cp^AO`mQsv_OkSDha)#Qt^wils@C`G^5*xZbZhA{{kVH2ta+ zY?nAaE|u@A86`gOZMx{^z`Ch~^_K$=7V^;d2zlkOPsUaRp$5iBgV)=Kg z!Pr5?y!jJA1VA*1$+J!0J{zo&$?m8+3+pT7J4<+>s<=O zQAuIq1ydc^s~PFJYkSuj!x7d&ma4QA_aBL4Ihnxn(=6=;q1#}6_#%0eVyK>$Vu|)& z32I>KLWcl>iX!t{+|h;{ySCWlX)1?5tp-g4EYU|IxaO9NED}R zp|(bN9adKb$+xS*b7czwyjf53Ba^$dhiAi}=6 zXj~HO-x{VhZ~3q#t$QIt#Y`ls`bRv#9QXTIcrrgI4lx&%&48WZ{fPk*X1GjtIJO?Az2;eT879RIuP$yI`^B021AavvF2dfQb{M9wzjdbLmfVDp=|2U5j>%!Khf`9W`d{bXh0_Epm8*z5 zP1(scL#%I5I1aD+D1WocGXk%iU42%^dhg$_oWYc&ELcESx-dlj=@QE_++=c+cTp6hWafp&ushi`=K$UIkm8FgeBSgZ$onbIr-qjy^GH1 znR49p?dSuI)p>uoCbLr8v#1=m8x}-hgc}T-6=dKB521RBv1|3lM=k;L$65zRcJ@cA4 zPmJ?z+?Dl$j_f2R_m0 z7%(p50&XU#g)zV{4d(!FTd#^jYzQHO27#1Y2^v7%jfZ*pCh057v@W zI@<1!lB!vgXxtX}$h&Dfdv7;cd2>sjBc>KVsJbP;Wpf0c>{HtBnwWAfma?>2eyBZd zF$GlYn96v$gOrD_upbs^z(A?xhJ5&4bN2q!c-1RwFh#j$HX8q|dLycj0)!7dqKh;S z&HvR=FIf&@iM;;TzgfCx1EjV)0w9WZA@^UYq`f@mxLaq|WhGCGxNSwWoD)NOY^d-s zI4tT(E#!>E4|4Rb`_}cda>s_Z7Co3%HJy#>yTQFMhZ|l?t}f1+?dFMFTYPGX>g)#OQK$JBtQpW$S;>)_S^hqCR}(gr?%Cd&(9d3ztQP@0e%4W zcvI@5EyZ+|Ir_ukXLAfqeJH>26*=Y{Q`g2Tjdh6tprjs->h16Z$WAB{*5KuFKG1HGipMcF0PY7_0NY48Hm!q>JY z*Jda~1)KBBfcf^1PaVi)MOERVJH5X`FsuZfN;=E_d5o;j^&0xgdBWxi>@~B=l0<5s z>nNmLe&0ezYDwRY!NLIMJX({(Cx6Dn6t~@8~WzH{1V^MAi4S$Aytk1O=fgMGshOIOF zH)yIBGx%ve8#?wF_UdoT@JU&}r&*D1sH`{Q zL@{1Lv$#|jKz2c_2P`1FPv*^$#!b>&--h?h7lRKA7&bF~Aj&l{QBqs6+cE2zkvQ>b zyjvSVu295Hyi^|tuf-z8wU#b#DMuic!9&$Zoh5FITO{`38;!0;MOw;e?d(YS%J;2* zka_pTZd93CN zjEjrAN%7Fu5}*{OR^RS5)tnn?;BewZY-B+0oqIDxdQK~HdzagHv0y^bY)7rL_rgqk z^+smE&DNh~y^h1F8}BRmHkiq=TcZScek%o}AMOsTa(U=0iJJ|Z0uc7t{B8M}7(E(k zzIE%9(^%rk6Gc7tR~&Tzd)+D^{*_Ks>a}{tG%oW#OYr+Cw9tM?$#>0U^f4^jSo5xw zVcVa1&{G;^4*PvTskfwYXbG6mhiYkGYEG|o=Gloe+89t;8r^Ijb0E3AzS|o(jb6Lc@)t5%K6>w+jM|n9k#0)xR`|%r(ns^`R+G)o(y(WnZ#~v?@n*N5+^$qJ2 zm{B$xd+>!767Dj(xlcT9ncwWS!Fky!$6FrTa@>D5EmF3U!DIC5lPrkOiaMM#dOh7K zHFd2eT-F1T+;l>IN_Q)U{_t%6>vjGo>N|SRa_AO_T%5z{|M3_Ha^HD0{o^%x4&Y-x zPu?f8O0M(5_&$+@s9v$0`TlNtH>*@F*zbFb@X2= zvoMoe#vURJF#JE!*TE5XyIe-ZRuKv)mNJ!GrocaM5aS4Z{}Z4GRDE6jm8JFje3bm^ zVeMoo;E-4a<+BX|2vZ02%Wn+HlhAu8@?4qE2uO4@2P=?*8U7$ouEZDj7K_FZUZ@8y z>Tm|WtsI!9gd&oF%=er=Jtw*Fbed!8LmeP;X58{?X^;GalVbFi*02z*?0?hvDo}At zW~FFENq9FtCR40rE&ljH-GSz+`WFsO5h+6q`AMgZ@3KTkYxGL*#Dl-%PD{aZ(UaCw zco1`1qmVfE#6wX_o}OVU5R4eL9EAy&cL~;KnddRML6nqiq z9S580??SnaVWm(^QiB6~1`&W}xdquOHW!|=P+ZKOJ$QsombEyF5w+B&@4O~ZJzUa$er%LFLThOM-Dy+x3{9zP za^!cyQ+iC|FHH?QZ zQxj`Xej7$Q4ED|qeJ~KaJ)fSDsyKYWUEcQmdT^BQYBiMGrzH`)=fXLZZk-`)nnY-@ z+wn*Uf+q)6BnBz&Pq`%b;if7_8%p%5NZ}+(|JfN+FhS~I+1fhxJU(j8KN2iCX zCm6kJD_9g(Ay?Q246zT&RYam5I#$fok->papP;Jg-ZhAWu=RIJV22z^sPkF;y8#)^ z6gkOPuI*_z?!c{M`I0qD`1Mw+Q`$<5MrFcM=payq_m0L5qt>l&dn#I=xwsc8BEPbs z;s_Ths%{a3KJ{qk_kROwyT68i09{_R5C1>ZKKbWOyGbdUcy!1+c5TNgxWJV)F!k=S zl@q3|(?ZeI8vkm>ypU2NKHoG$Ig<9jKmI~Kukz&cx~H5*x1mkxWIQvW=()AWnvAMf z*v4{3RzZ|i17YxfjC2p%l*5e0T)0QM^xULOOY;C@=zUuV)X8?yBX8h5;t83=GWn{H zvo~}p#ws2Z`Q)D_A3kUqDRdMzxLfvVU}lnceiER=8|x=J5o`TGdR>K$cP&8yW%aEYm zb#jaSDQ*Xl^mN2o7XSNPj>$`7HU2%ljcV7O$7??~@+SS!3jD~{x?@~TR+B%;dRaH; z8#i1y0A^ZHNb@$gvWHJ8w{A=AgVI{}>I5bl8tF-E0dC%ChTC+pPBjOfY+!fKc3;gQ z3)zD&=Dj}6p8ugc_N>i^zByn(G->P*Tz?4k0MYf`VCTr~Lr!fSS6Q$=_P;eqwKplH zhn$V0#}YJ*Kla`RVTeJsg&$x|(>&3T1R=+F_aRhPLsF~di#CPvr)lzf{!DW`1$3y6 z+%yjFLm6vZLq*@3*j&TB2!=^%NSU=957oc%7lTkdZZD4g!JDk%RJFV=9pjcv32(Wt zN485>JVJDRn~NNKrwjy~Y#}9`PnH-6JH&4|+@}(hn%n&aREgH~D@c%6YS<X`C-ELoMQC4-q=NgUrpfx7YQf8pOgE1jS!betLTvh+LV+E@G}&hUdR?EzTX zcxSdrfJQ^^neI+3+V4stPq;^CMFaS~Uw1It+B@>u%xOi57p_->b(xZg>6~Z;h(Wu} zD^P#!LVIhIl@Iqb7PVnlZ?IL6IZV53(DoCM`%+Ue+n@nTG6B}c*_RuOeGM~yfbJ4P zgN5Mekv)yIbV{<0V?BKbIGW!4`^gHT8Z9WF(zum4-207_hAGhMYCD$xC5N(0`j2^D z!M7iKs3|JPd@38CX-Y{}i~%fuAqot+gk_$N5TCOYCXv4A#?2>fEtIL(936t8Gy6FX zSTib~e(TYy`@Rf@JL}8Tdn-2JVoQu!pP&2kvN>8B2#8V)YkCWY1%2~baoH>R&=o`_ zq#-l)*=Ioiw8e+5d2YMkYyK({mJ6Z#`oj3Dt)-FbFDT$Y-RR#R-bO0j*BK_GoPVjp zK_nd5Fq1RHS>V-cWSASxkzY`lC?gCymc(KsqxHi^9toAszha*aJXzye*&G9;zy48D zO92M_YVk6T_e|~rbaOSnX8+1}+yq(mO`a$xzB6g%Hp!`V%2KwjvoCQ5uXh-Zx!nse zNKH}r^*WXNbNo*DU=JYYrR8I8+_==9OjT`*{W3JI7LAj{GjeR{M?2tQk{6!3s$f!Y za;z|tRdkoRt+)!KPLE$6X_NYP)u~0YahG6Z;!Xt%qZ?xPF!UbeR6nCuA6J;E+lf;2 z+m~UPf>(+*fRWzhBT?3zJ4^Xqo6FRT!7PuD`0^vSDgbsU_Eb+ADBy8V1(dT-3F_oyg!Ct=vemMeMdSY3&tMuIC;Bsj3-1xhQLw?5m^-J2~fndRbibGkFz14H$vpqmfAW|gP^;j`_Eb(XW z@1Y4A0*`}L8#l#(T6_5^J>GbOD-4GnFKPY6tf$x%!{hrgSmcEuXxqF)F}~y5RXe%{ zUwP6zNfWk_xmB;QIDr($FQ#kFOuYS!i6_bc%}phjPeZ*WM9+4S=n?@fv7QbD;J-2| zOT07&4b$R>IZZ+RaN-A(m7lZRH>_7cpz2n6s8*%aEPo0P4^JbNdeOD0?>Gx@tWGJg zU_AOSKg-EncgD4#!h+g{4&>PODpxl>Tl6Ab_>=lFT{GQRjL+uVLY9Ig&;O5d@#`f2 zL+e;QA5M{EaxG1ecIyqtT);Y+o&py&U)bV8(tGF2dMl&zu=pClJ*eyvY*i#&jh@8b zd&8D>H|1^jv-^p$4^F&Hxsn8{zJS8+XpM>E0D%=eP_!oXd@*E22M0UyD-9C5q^(L7 zXJnf#HyBy^+oQpps(g%B?0zg5eYE+xh){kh;OK&U{j^JTm1dk6xl=>+OC35C1~=;P zmQw)1Ci`=xT~PtL0uKjWs<$DvixfP`gP)$E?|8o37CkL=v(DVmhMB}obuf>Sfsqc2 zeYxDc9;Uifp2WA{@h0(K6n2XICih#G4i4mm-mJ?3NkL0W(>!t81RCNp%1xMCfy-E} zkfyO@gq2`17D2SWy`!t@-!=tA)SoRE@h%!op?iUJmTiewm!F3E56>B^X+dhv@26)) zuB{~_eqwi0cIb9&g?eiu7W)nLT(YC7)ws&2rY_za&e>oXDYdmqv}}Qi*XxD9SbfGJ z0o(Z9dHst>;a}J@%h?1e2O}j$i(z0zVA1}w&t?gz_S7A{cV^|sYLk^tVXC=gsdcJ?WrOe7w#y3!qg-G` z^y8GpEleq_DX`%CGe=6+j5Q-9a(|HV_&AHtj$?M zoYW)r4%eV;s%Bf=@dRNr^fTX0#|skA5ECcAyB-$))`k3A2@g2+`0O@d%A(IR*`(K9WZvSB6(DX@BrFyfOAM2I5i$`n)TMN;g5rGoRz%GR5Jd=&;mc%4-$80?JE6S zT~Mb}$aVUFvGdU9OyG|B;R#Oz{bKZ^PTUKB@314+mYWyHl6=43;)sDF`y>K(^rUvT zQxDsB5)>>U1h)D+^=w+$mAY(<}miW~KCkbA5`(g>$=7zPF;;w!K02OVyX4ek8(@Ek9 z@y3E}m=ebD^El&@JhP{kD-LYfimDXxVKaXJ?atKLXeh18(0&E|X11(bhEu_b#hSRI zL26gCZx{a;IsD&;hU@ZQ_s*07TI-pRxwPt&6g&`Qo%+ki#!vt}`l?4dJyhkNPNoa7*VDDSW2(WQ2#(=7$c zu&0G6V68?tUF!zj6nn}sMD`X(G@WLgRHF~-b7J?Ggj)I1QHX|!#0{Pe1%M<1R7pU| zzCZLXhm$U4)<8u_LayaG*5mbV7jLsrdZ$1zki2A(Cr*(~7X1p%@vSxYYXK-Y2~J^`A|?J|n=9#^j-rKePp%n@^bXEvcBmi>P8QR&w7 z3v3u!-|a&25Vs9OL;={5D4yUeZ_%MEhbDQ>2Bhpf#V11%ZGLkDY*^W;Nm*hoL^w6e zy?_B9OhsF*#JcirsDj%AO6_1uT~oOO#vnVfP?THC@M5;=SaS?|Kqa3vMC~3yIvM%f zi1&9h_fJ11aP=>NcR32`2Y<$VVazl2eD%Cw*|GJoL5?6brsi-H?F^vf&|6-^Ki&!M-Wu0o zKtDS&*dX+YE&?q-NEL8gUGSU&81S_-oHCzN?nYk+vuT&f`+s5_$W=3tTjSV@w;ygA z#fwbje7b&|{Dl6bEZ;a)Lp9VzRl_%DNgh_9r4IYvff}C=qj&q`Jb5TnF1LSmn+OAy z_~W%e)m$4q_a{o9=Jj`Mr=EvEygDkQEC(&*@pB#H*o5bG@CiBXodP%hGv-7>M=HzI zCR^4l|3d-Msd=*;t({I-Y?9oV1ht2Y!bX8eR64^!Z4~mjy-kGqSdIfa3*xW{KZBhc z*iQg|sR0~EA>V}}A5{31Yr*I;c8$G-8V>a93*L$XQ`Xq=4WyJv2uIqi?xtU~Hx4LK5#FW)F ztQS&iuk`yl^%s^=vLC7nnTkM~7QY|p!$q0zk;51Spcc;*_} zVEr*VL@Rc1kg7f%m-unJ{o+SSYKoD7eokb@-3|pR=mN*AnblDf@M9G}aThvIqq1<5 zhcP^;QY)8ODx<2PY99yN0Ka0FR5g50OMS^}rbzt!4|YTG0;uUHkC|YziqPP_J1*!Uip8(aX710Sdawn_Zm{IbsdA|1#q#aXCra0NpCwgJa$Wzwd%ig~ ziedUbjl6+2y-(N=hO!Qzg*FE;usPW3r7|Z{y*(-BKG*IHvK2#sPM>w4nV(lH)r3!s z6G)dkH(y?kIu%6CaCcBZryA_Wpa_n6HsbN%in(HEnIQ^Wf)HDiE61n$)@2}}> z0W^*qH?&}bb1R#ps3}*^ZQvDi&)izD6cg$Di?$W-)bqZk0TDrx{g@%@v(RbSW!+~JemDv3lehUL;N!Qd@vq9(E>5W~T%wPqBwV)zu}pWD?ZM#zELbQ* z@bY0PcnHt-c;6;=febqqsS)?CRiLhCae)G=gwm1er__iH-T7=zqWHp0hL|O++s;_0 zdRyJF0&v_1(s5=*ZjWvq)1LITPv~lPkd=}o_O$c8$(mqZv91Bx5fMOfoYL{pi;blr z=?6kIoG4#<$eYpfyhQTT)<19f_;tXF%I509$6o6^D@4_;Pj~bx{<+A10(jW7wORgE zw(C~`Aqs?KjbtFK*`7Q&yMiz9 zm6bo3;zUG=za0~t1%`%Dk*L6Y{cuvVw?M;?%q@mHB?c>aHezzbrsv$G?163->4_;I zL-BY3r&cF}%u*%RGA{=pifn;94yx~TUG0gN*SddZ0|9YK^;(7}N^GS=m-Px(1G!}< zXNlM1z)vAgHn*`3iD$LCmiCwB2OycN`=&kq<)`~~;QH-zpukxJ5a0FbuS|rlP?5%N zPz4ECjB}V@_TcWwmqlC676XN0s9k;qFIfgW0gjXaW$;ilJ#>5AxlI=Tf?Te0foReE z`%A_NJ=Cr~+jSdlFJPwv)G1RPpj=JnsC*bV|IPwH13|Ialctz=3i@fMR)L)2fzoR` z>p`H0HS8x6(AGy6sO*+NH8dwf`ewVG@iZqwxgHV$TjsD$`P~Y^{cOYKo(Z|VF_JHB z`u&|%d{OHzkH_$aCZ+u~_ZTRwKwd%$71TQXs2f<2)p)(fj@#$kO7gBo&A^(cd86@~ zP$?ZSQt;N+efR)GS*Obf(gabT$T(q#o;QV`ozuDRE@J!a}&SDpv%@OGkaU7rrdfh7D;$) zY;I3Sxvt$`^6<|my>(bg-dj3tS4-U>h^21RNts3A8P=5n5Fn!H<^_Q@Ftqh0C``n% z|KGC#sBiZvaO5ntiti)LkxApa4nR@P?}6-*l+PmXwVimQE^-+;X`zW^`YdR6ASV8>j%nGw=w7bc{)j@=T`pQ(zVL%8Kocl zY>+7IGaP9YmfAKAk>OctYpQ*U&~=-teEuok_pztHvk1 zM-qwVfa6en4kiNM@0eU=DZaox`!PPH{o=+qyh1YT^-3Q^1`KlbJF|msEni+?_dZCF z#+9zm{+Tc1q##;mnt9lQp8{}%0=!oiUJW9k{P|%h(I}!6Eb2g)sB7nQq~JXt-E=1j zXP77MinVJX)DyjyWT2%Ft|9xv@P-xZyHDz|yQO|rH_=DMFXH4|dG;=cOxgUj1O1QA z@1L8a=^UB!kA?zmh^emGN>lFhrdx_yd0r%{yJRgk8FZEbx$$+;T2Moe81nju?PjyX zb0_IJ`{##2bMLfGsyRw&DFWuT7eK#MDxeb8c!J$vfC&gg_E)d>^lq{{*l{<^m9 zY@pA@0NM);Z<8NAC#i5d73Kpjq5EyEMQLpyFLi=n!{ZA~CA&NUv>y)b*UMjxiavxP z!#z7ysC=Gv$Qj)NTm>-c82^BS?$BUD*Ej{KsV>DVn|`mTKU;FD4qYK#B=+9n1>6Pc z&_qu!FhJd>nU~TzGoqW?uK?1DB5okvTP}Tbyk{jMzAs+jq`(F?! z#3Ed#tu3aDkZ+Ls&^vNG8!>p7hPBM)o0Ncj=dl;Q^4rg# z7=DZKGEK|aSFT0`o3{dZKory2YY7% z8v=Jt9hBHlh!@XJg)UCXO*LJUd$}QpY9vf~^?xLaXEuz&+8#D?+KI7FmdBfV?oB8Q6OFj9J+wp& zOWpnOyTat(>b-|r8i+eEC%YS+D%Wmx(7V`;gR8{wJ=(gSOsfNjCev(dQlDeeN+t2` zS8pxU2J>BjdY&fBlF5S*W^tfMe*ZJjlHLcA{MR^LfAE+Ch3+Y@5&ciCWH+T}iT)mN zg&M?ioY$*Jy3wbXifvsrkX*m8!R|1vs^$Be**`FZXEkEU{mp4dMfsM;zM!6cJRe9W z)WHKn5JDaZX0Pi91BUnnO2aesbl6!u*>-@ul1BNRd#{}^PIvRsmnGs2!JTg&RlbH* zT98jiH#)Sk;|eouUi!d9Vy`~2q~CxtY`R4BVIZvpBT3qtxWW!vD8gZZxY2?e#eVlb zK!5>A3qCC2lziN0cb9VmH{^!iuoh{9<2KW#5Ek-4Zf_D{Y1TgU9iJvTIgd4&(-RgP z#jDld!1nj|f#fgo>VX z1`;f;s5}cgZ%$C{gTQ12p&q9BGVIzM47}0MTp+z9yAV#3yAO6DWmPeV7$ zzqcy?^&V*JIbhK6@Wthv)10dcH`v$d@&AnNNo= zk-bwx23`>#UdC)oc4bt&9a$F_ZgtpRGECkE#M~w;s(XLheC(qmV;IM^Gpe~B8t&9k ze_;M94>3Lj0fnB?C4><>Yo_qoLrSiJSJuo26_DQ0ini=wOXqY8{O1eNUwiq-?n26} zCx+S5FSX1$`FxN3NVcO7WIYx1n>6)~H9<)a)e2s?eS7(+jj{dYEU%7V`g2*p(3AyQm233b zU6yG_eO!Fi4g@)0lOT^OZltQF@zE18udnJ$e()$wQ}{{!-`xZzv9*!@BS3X??DG1Q ze8daIQ+Gd{_Ih2w+Yjp5=c0Ndyb8U76yoHpZYAyqboRSH4M<@U8g!MnrdEs87ZeNc z+l#{oXItm|s2Hr^1p{%yS{Tny zT8>>-?}ZPSX_?E9_qw@#PTo(8MCV89unpiah55E5GF&q&@9iW;O^)qoG#WRQG{ajw z^DZ!0mpUr=qwMqDOhiq1e2gGoJ?o`uP!De*(9~2QEUq7k!YQrb8rD0;wEydT{SPel zHc%KkcD5{Bp*~zQz$y(w4#J(B%hLRW(S65#9{vR#aPzkhQR!foyqrI{1KXq&Vx+Yc zMXffU0B!7b-q1+661@+9qrAK%j9!o@367>tqnRuBQc?Nz@MO>H-)Oy? z3GO>~DtP;5oLNZv<2sOC%M`IgO!!ael`_$_i{+mdFJBIdb}aPSv08W~!sM(p=rp;~ zbaU1XH^W1d73vS2dJ#+1kqY^2o-t?H`)NIS4v{}a&RF??CeKlix>}Ce`uHFfq!`i) zi|26f-Y!xhQCVAqrs7iVsM4%sHgdf{%8X5k^}@=tr+plU16OAwc_*eYT62(GgWETX z;CbcC^mrQ!>T6#&*F!-y-s34Md9gg5t;3jBEtiU2sP|LUQoF4(DCuCh=>yX}m|Ge1 z@02CKc5(iXUHKcX0WUfuY5^1)mx2>fzd!=`s-Uk?)OLSUwPq%P@S7hLq=Hu`nfS#7 z9%z8B14by5X=!o*z~85(pgZJ4wkgO{3PlAwCZKKY%RA@w+-)wZ_)(gu?vslA3>pvK zxUb+X2BKPr@h&;28Ho=bM-)`h|E+_s^Iq+#rbloM^3@llUq$z#qJZ~voZs0k`nz3h zTQ^U&Et48{E7xfCwq3g;vP<)(jQUoqTl+Q*NG(75O6L0G&dPJkV^1u^#~=R$8JKDL zkTbe6xhK|7pFRoVC@`Bqih5YnWhvRfC3L(E5UmlPpM*yT35qiPW^;{; zwqTpLrYQu=)O!M!<#gt=If^5qo@%nRj5Pqw@ouslHh?@QQl$LGLyYUcghhJk* zltCOdZG)<&vP($V(Ns`brSVx zpN6Tq=xQ6*7oov*yscK$93?7t_*u@D`)xI#A}strV7AW3wV;cpPC3ofwp0xHyAjZq zi<~Vx7o}iIx#{l1=m)jz(H9}2KtdD^Lthc@4}t2@CQ;9rh?BM5AK;}T9TU1siuI_c z-q%1P2Zs%khy!u6T0Uv~&FO`p^CEGDhJwz82Y=xYYFNwTA2nVNmbaIPw!QceIG2lJ zxkvtW>42uj{yR1u3-86J_?I|_Vz2M^_Keso*XZwdwTHXxaun*OI?F78k}* z#>nrI)e1rRU-xe#0K5;aF;|v&ts|!MSM`?&?${+s?5x@^VS9aEYBXhz+L#Q3O3)Y; z)f2o58ViqR$F}qrqS~#CYM;k%(esY2$2N{K_(gYd2-#M~TFt^>g&sS<@ejdaq5C%% zIo?M&-xU_KiZWMnAx%<2-jQq{jmeZCngmtL6QY6{8(ywf-dFf(!c94>BnM0QB*H z*S@o$?QFUT13e6c%lz3>U3vD>x;HNvLCIDRwkl*Y+d-#IT&MlXhkx_{3f}WQx#NoF zfTzO|d#%?N12&3#>&Aa8F6|XFiLTZ5|G0d1<=Tv0oP;v}!?_y~PJ4<7!)Lp{GUZA< zWmYFTa$3)K4;NLIK+3bA5yzG#K|j^9safCi4d9CH9)`wAT4g(~d91lX`{H>uH}juD z&N*K=1IQkj59Q@SopajH72Z_{D;BPB1UGZ0ewK&1@qiY@Zrzv2^8#zqUaPZ_Y1 z8b4tU3wSqi&z&iqD~<2sOLBZqu=&{L5LG#^&m|LDC#RBJEEi>!R-cZN-)p}3cf-a% zEt%-`F|UmipfBMnSUHW$*ZNA`Ke?GMIzFMKKFY(B4xYt9$f>GzV0BZCTSY}6KwP)) zDjT{ck_V~y{6bK}su#u_9f|<81BJJf{f6Gk){@F+54iMJ1x$%LH5D-?s=&4>Tcm7ijnA6^DYb9(ec7}B&W-3)TEjdNSiBK=lG%36CvNga zQ{Rh0?LQ${5ADWl5|U+;DtG3N4X=81jjzMB; zm?85O4GHScN9#V<9@_FpDnjtYv9nH(IvE_S){36+tY zWGLu-89Pia*frcKU$|v<2R?1oGMGu7E){V|_Dt-qqg*(I39-p+wm)NxNd6f;SZW#w zcUwO7zv=$`$oWYRj)~+ifv8NT^MnX45ed=v8Rn&nx)p`3p#!}~PEP|VrobLI9OlvN zIax5}?Gum$KLdVd3H0r5y8nR39xgFu5EYg_uD=RMJ@U^N+yS+Cd2Ucs%)@(jBWRw4 zKcQsq5K*9fjYK1-tbVx!2VAsUqTH44dBGLj4;jAJ4Hkx@+t(b6r~QxIY$ss*4;ZC< zlv102TaG$QdUKU~9$?eyuEpU1J^|GKE9qXtJK_}5(h=GsU{gd_1ii1@l9{KUS#S7g z$eLDGNGm+{-h1=A6WjC_3cqX+72Z}TZc#vY1)q=*E;zb{XDhVq4Z#QY18mU_F-d5z zIGQx5>DMlD6lt+9X>(Y(EEx3hlH!b9c+Orq_;8wno_niXDbJ>ax3C4}7WK`4Lc9;P z{(-rr>2nD4tThe?foRYg3_@%}Yz^POH5fMFy=ZM*<8*L~;fA3vChSmUI;evDf5U;> zT47-BrZ%sju_ZHA=n7YI9YSMutvVij2_5gJFND6>->S1?V z)ZqF*7>WUakpETwrN96b68^$I`W5x;#x85n0p{|QMXXuoegVkWelPyBQ$BVC*t<{| zkm0fUYb*x`>rx-8TW9o|a0_fKxv| z6u(q_a#*tK+=dEb!``^di)-xQ=$%zEh3S` z^?$0K6(^5yCU}vV9gdb?A-bLA5tsxgU%R=Zj7V;|dOC;^%erkaNWsT1J-guBEW6dZA)`o50SQOQbiD+5q$O6;QD$K zzrt{nohO649i`b^(0+e*G^=H!0qiz27|VndHD%gL(kpQw+&_i-<|nKmXBw>t^Dd2t95nSelLv z{S^$apeAXc7P!Ae@vwZIv{dMNr{}wfX|J^OFr(^P{^;}^2 z^D|{0ZV#MjchBB>IY6+2c!>(D14Uj-9U~5Lb!Xr3Ed4BB0a!ETSXs?O!L>xgstrIW zzJ{UV1#yqWmR`BKOXl5uvuVoEABsKXEeH$QG~K!BtdMF2dIT!Urq`WEj-6YGiK^3)A?1^3QH5nd)?37x(VjWg9C^MU` zvjvj7i-ED8r}E-8Xiv(l586-2uw8y#>@n4i_4^QR&|}w{8B{n7Bo5%T^@AQ?rt65= zfBd@<;eYV5KJO8$SnE?z)@`Aohp3M_v8LmUO2&?C8(ikBYR;)GJBBfzHo-TDG=$R@ z!P0l@;aNYSYo?>Uf3ver(cbS_IhyzhltH&t?8d+30;{@3Gz(W+Ut@U<{Q&mlCwmj( z^^f<&-h$@Zy$>q^@*o;eHemB@HX;OG_`U-&v z?k?%WBhT)BSt(=P1kxr*y!k7%^-KB`Fty6!#PQSL)**r>cd&DrCxxo3=XB3r8?zH97kp$i}rkP>P~2FOnM z;u`bKL}-2-Lgmnj#!r|1l8l2JhU=oUL+lLaPsS#?dY7?QFBcd)s<9ek|0*(8r(M+0 z5KO(_6LDO=G>~j79Bb<7{*_zPoW3UJc~)Ofg!|MtX5s+TNNP%{ z43^KEF5E}uVNT$T*!R(KE^hw^piPQ7CTfKM!>wh?0eAP>`2jGXwoar@bqho(>vmiN zLS!0O@9+GQncTklez=tLt>Z^I`6XgVyV=d}ft@eI>yN-+Q(WKdp?|KJng1YH`TOhBh{R%=?R~p1cbe3cL zT$#I|x7(UewD%FTg4j zZ>4<#q^0icRvibo4Ad|BQKCtkrza1@yyN(U%lU7h%KDv^Ht@an#KvM809N4u_nP`T z+yf|U+%%HfG5%>?d!z>Gb%uN#=r(@PxpMvp6U=c?Ts`z|yXB!LN32qhR~+jF zG1iyfR&05^Wp=^tOlnGa+;gD!YtouJt$SJA?$K{y_@J2mQvane`Yon-_GH6HsokM^ zm3<@5ajU6cw(pf;-Ly=jlazx~WpA{%iW+-DTo{4%BiAK&q*;FzTL+Ykm>v5spf_2l z0JhZ`vH5+U8OrmUBvx&ybUn7iABItux^DyFtUDSYcTX9lo=AEWK0pXZ=O#8jc{GQ` zMlsV%TC3!j+A1<*S63(cH>O{^h&iyrs587XB)>Uw=;_{v=W)SD4&d8f`=B9+?a|7R zVN4tr z1RyWk2k>n#E}JM|{iaOV0f~zR<73Edn?Zj@2rI^v!9@+qIr7}H3LuH;Lm6~eIW(ks z-nF_};lg;eHjTzEJix+@UP5m76QA{;)+7t%cW~NsU^S zncgZPWE|fiCca_Y-a@3w~QE@G03U+@9syt%g#8y)ek+xH&3^V1qJ z@k`LX`qmi0?11ypXA4PU6k31p+^ab2oAN*?AOLxSX;3oe713yQ4~Pfe@+(Q}&+i4@ z20>}1G%)*!RW-PbjH5D_=w-bgKzGF*v=VCSff9lk(aJT$9Sr1T@Ml%!RJbDdYB3z((u!DL%Z}98)r} z2-T6jq>}Wh5ri`X!B1H(HpA8677PZ9At=9RicktR6Vid4s9#~t_U81c14PF4>tPZIMdJH9|*-O=8))*eDg zT4HQl$iZ9KEgnfrOM#J2%ok9TS^mxFsvfb-*;guj2)&#qqFx9x%b9riIR7$*nn)`5 zX`M4^uc$fEt^fNKe}6#)2(srpj~n!X%>#QA_FR01?G4+h)qMBXgFUxSJe?ulCmSn< z`gH|_JMiSEo8BT7R&729DCy&}155TT{O)KJ=I8Pz_Vh@{ftqUnj?{;>^^#x2-$u`+ zRt=9y9#uw;bxYL9J&{Ra?NV%QLiK)(_sZ%-jT$q?c~2Qr4!%uAQE)uZVca*$7RN?m zV1(PMe(a)5Vsjji$f+8)z{)d(eDMB4d~kDlJB)P$o&-T3^5P;IRQaQ%NjrL3YY9B1 zrnHPuLXoe5Xktj?7EYLa-Z;-NCp}3JWRiuaH3&$zp>8!n>4r7wLca-B42~5azi(2V#cwq%=R2k4^7oCenA^Kn_*Dq?r)UqjxP}w zTH4BiBm8Z>{`sQ&_+DIv0Eo6#&0YKJo!+TY$qwzr%XlE|q+dC$qknvK-P^81mAONv zt31{I1-oyGb*P?9OrvN$-+%eT~#!_xkey)Q0RDW;%Rx3?Z>HsV5jDMpH zt>pl)vJT!qUqK#o&RNh}aub1}`M9h(*tY(j=4 zt?GR(kdWrhsq1BL9V09fmKq7SA-hXGCaMWp@gXkGkTX7wvoqebxx)tz0`b`{RTXtzv~g*DWkR#mSqL@}Q63{9zCaJpZY7gwvQ zZ=9qJ{w?-=ABS|6#uzRmgzr{j!#r2;J|#9)6ik0+Xc4+ki0+p!!SqkswVr2p^RsOF zVXQ`y?I0u(3r>(4pUbg@D=-YWoTpCXRoys)UYIXwq0?(WQPvVGUo|r>5TNPOC~Nmt zR8oZFb^N2IVHe&fD(4#eA8YaT-h4X;g)3sh+3Ju-u-^RAr)q|vDK$$!Pw5 z{r#AQ4jeLbCO+p-&f*7*@6XKO!Nq}{jF4nW(@CFLj>!|B&!?jvA8oK6E%(0tQl{X2 z9HB9=$w1VPOIA4tyWMvBWI(HmH;d|w+~GX-)JA3bv$<*Ch+U%vdO8yBY;C_37?;L; zB^)+g8Q!T7(6c7z6UKOgj2x*h!{V$usGrTSgNH^3=nE$0@nsGBzgc3@F}^afHYQ>x z_UMqov}G!Ge@xXyHJa5Up{YAe;mOub#lpke>R&A>c274%)Wifj-)jjEd$!GC(;Ay& z&YN8QwmGb82erfo5=TQ!2fhrQ1^O#W2g9h0T%dr^fE+*PvI@Tlx|K zqELh8dcYaRU<8zR=5&}4rKBl?{ z<38wkrC`vlV^)h)@+uE2=CLf#A#UOSW4+G4s+BJqujwbDJRqB=(Go(_;4Kk>L2vC8 zSJ$yO}9EasF}FvuwAxxlo=4EE zJR`k44>qZ-w(EOh`w5eAY*)Vvx$wyz*O*cHrX|#BaXAUJgpJz~>Fmhpi@pp53&nUv zQyorBm(4-8a-McTG|^e!m9*;ny!QBu49nbn9#MbGQB}@?Q{SEfP>O;6z= z$2-x?{687?4cnwm!+?+%5j|!J?^^<61h72K)fr7INH14ZpKm`Q(ia+7M;2LjZ{MF? zszzj?`*D5cdq#;^4lyXL%J*fT(ZF;JOBCV&fR;EtAVer2)?nO%?Ol5(bW}}NDC_#}cpY3(sVPTyq-U9>iZ%!`%5ipcB2$iIwDp?~8}L$iq1{3h zhAw=@JnrS1?{5urb00+Hda&o7I)&bjZnGGJb|X-HRW28?I}db}82fj1ahGXQ9D3Kl zMY{J=e(TbqGHysqjA>Q9Cq2Q(f*`A@Ah+5oF{qyK&39fQuSJ6Uo4Lm{3N^@wg$p-E zRUKRT@DXfls}DSzFY!G(?mqi*4m&lM+6sCQJ@jx7FPYcUE9__3DLAtV2b8{E z8UW>%;Iu-TY3PcZl`roaM5q<3U-9SWR0b2+O;HE|8(BxlXB=>K&RF@lb>$gRqHx&| z=#;f`z0P{l39~5V`<5KIJecK}anf}BeC)W_ZLX*63vE-HsDqMa316`FmKI)aD^3L0kX$X>7B}r;Ro}qYuMRo-NyFTMtaozArhm%9tqYu}z$Ev+Z9@+J6r* zB$OOm@{Lp);@r2RqjzVke05VDdmo#I^4>%kS=Y5xgy%}WU;k#rdxw$1kb)9r`*V~|jI04`SL;D`&zRyu9$306e;XnQUP4_O|+Tv7z4zsDuik+8*vJWZlCQUc+sZE=brkV<$#lv8TC)EN zVeZt9{4xP(P*(M&@ytWz%*}`nm&47N{2V(-!=%V3YBYs>{8^#mdfL{ek;*gtjHwVe zzp3ECss@!E-L4wgCJrbA(PaUah2sCt7M`L373g}U=;ypGVf+w3dB>UOu}^sBx}+Sa=X zvYa`aT&G+_@PxA){c_d#x`O8>lh2r*PUG{D(MjCtBx(9sc9kv$J+Z)r7cU4{dlG(>_t!5)d-=EJ1>)7@Fb}_~> zrD)*u5zkeB9A4Om;5+$s6B*;ve3Ax_%SI9POR7A5L@ zUy~N63EiMhZO?&d5DVlHAJ8#_Ws@Wu$CDXp>@0{Mr`$J#dWc&MuneAKRm^#MKt>Yj zIQ89R!K^w*lYTb_%0aT}2BXGw?dBj0>~0-`Wjtv>Yqc#DpAzihoGw%3nqwyQkAwa1 zr3%}>K^9oIs%wLSa-tU!sD)42)?C^dP*Epmm}5A{T*{HS|DV=JcYAU)8viKJ{;i2p zrqncV!~LJaw00D(@%a_L!&33^i`s;?)x08=F)yD(GEu&FZ4-+fb7k2c;fp$pBY}<& z#&DG>_7(=3h)#Z1L1zMD_e*$iL(}kKpzT9PlgoVvbdm0Ayu@_hLx?0!vnHky?XFho zn!i9wPhx7D!RY7t{VO9)?L23!StdGyZQrE|HWro<4j`$!Lr8IKE@Hp zhVr@$_7fm1bRu?{?Gz9N1sOQ2iHAq`p;D5vCiUSu@FegQvmn`8W*G1tzyqm|Izr&2Fk*hcX3_{1di$&W1lj+C~Wm8Do#+U!$`qegW9I@Nl8WDKvt%L+T z0(g+*pVq9d$B4mxv;3{_*D&U**%GTNub}JcooNw$X^;C(y4B!q=hO^FdlF1v7pR$X z9&qglStggtN00IyxSR)_%7{0<*eD$5_y@z>#xC32 z_lNk;;hvaE!auZ*BAVcmK3)N-m;cwpQhQ6x-r480j!~MaO{t%-k4`D~d$$LU0Yjt5 z(x>nIyybJg{h6<*;lGVr?Rzm{NsDQ8;PtmAijeI^ow$G)4N;i5|H{upK@Dk1wd0$d z!cX z;cf)`C{)PR;s{B!re*qaA1@xu4?$twH4zCM!yKdZx!Ylj3AC2JYNoW{z_bWI4u_X`BdvFAAQ+5V}%;g-hg- zLJc&jE+6Fe8H{BH%5_rvQWY8B%6pHi+8d+kp!Y7yhoyjx(quxFvcK*e{kc!?&desC4J`O_~+$TiJD#0eGymZ^iI72o~vR* z&z++hgnOuM4*SC@D=XR555fy2{;D#`jarN{qa+))yqQArgDgH)Gi*7IAZ$93t$bZ|nhVH=tdtOTZSbGoes5|$(|22c#J zaXuW5Z)RndNvowQmtU0bW^EEMW=BsVJ+)t8p&m9jEG&>ZT`^;Jv?f$T12zjka?Po# z)1WtT*}{F!gRq}BzJ$l}4K1+rqposU1({W)T`1Rqd^L;(lxF9=*zk;IgYpb-e+B9fTYsgzsa&B zPTD2<9%A!RnpZ!?V)+Kw%gY#?%EqJI^!%weP8QgzP#7K6wGd?ahS6voDc2;8fPS2)h}CD3sX|Ycs8mTCKrAb1O9of4Ea{`r^Gjb zL;0s>W|pM1?G?)rvf3xpq)X#S zp+HI`&(q-`)%h4XH+N7)_!LJqG+iWG8maJ_Dn_fF=@@{#arfr{7r9@PVQHP~)Tl+3 z$&NQ@EF#AoN5n&xea8h0)gu923FTO-GrW@M+w9Ya=%Vw{H9&$7L}&S$yb*La{fQSE z8skH{kxO+tg#J*Gr5f1Sk=!p46gvAcT&Mjwv`sZJy>7?DfG$a-DNX?CGQFQ@`u!s zt%0>arvkVuJfn{M`LyOuBmhzT%X9x@pm87!eyZE_6w_cx4$R|*K~A~ro!EEmiiNo6 zvGk`>?Oxj)PJJZZ2(_Pp8Y;!mPfZkW% zFWi=6443=u^6+LSae^j;tg=jEBcq|Q=jWU_yr2v|xsz|{XlG(3SnPE!Q4Otn?{2yK zl*a#d^^RSU4g*6mbw!ZDV)hCEhHqwq0++sM00j@AHPw|s=3tSvh4G~Xx12AUPM($n zMih{JR>yEFxs`&xrPG|M#Bj*$^A;VPuo)`T`9GXSHvHMLPF6=Kb@G<3_)KUuS#L;R z5hn3u>#4C7oKM>`aQ`dCs%$3P3(|iI+y6_5jvya%Kme87zPu&+0Q^W|^rg|D4~5+H z2?kybgs3&?o^YD-o2&Ke9`6w~LQeW88_=ZP7_{mgj7KgcFBT#QgmDNl|g=t(UC6zv*yh4P2q%6>A}S z6yLVAU- z?5o~{4!j3k6IG30lGT|i0coV+VmFG9E-$m}htZmfGMwOagS$(dYSQX6-n)g)Ev}RP zI033ZryrC8Zu7ZsnzTwNnZ(ST&Lo7r6O=wxZ4!Wi;^sJ}XP15J-3RS!v=+KF%khgp?MuA*5VU)@Caw-!0Sc-+kVxa=J z(C!J!;$(}+KU^=`ycJBieFM~7+(COyq?+F_dSmaBgoir=vHcuKJpoxAO`N?|Vi zZjhLGw(UoAot9?vAsX%VB;G^G4*AEp{kk$&9Lc2BZwVws`w5r~{>%8FYHjQ8E|2#M z`0A(dn8?xFWx)7N_felR1e*N)H5e#8sT>4vo`hD;Qu#@f0DIho6Fa_Wmr*weo4Id1 zVjW|jcyS?I0Vx!iM}H@%C~>efnA(FVB6(^Wj3)bSArU+^m&YNFrmrW`8C9tbrhdGu z^)&8Xvv#w;Po$QZa0f^ozhCq#;dcnJ{tjnnQs_QU;U*p`ACA}r_jROdhJ4!8GPOjT z%HL#;)C*}w$ajQ@%1bfEGr!wFoRjSTeD!tpG{EY~C)xjH_`|7iRJNsUbwdc^6y;@} zq;1oTKDXi5?eoWvtek$UqzKt@m3MXT0dc)YcQ!}Jj3)Tw#CM4!eLku~_Sl#OD*N@G@H{0VG22eyiN!wmf(8|#o z=N&iTOQUdDwrhVDc~;Y-X(3;YkHR{dk2(1^p~mdS8T9>x=8N@e)MmnB{8D~XJA_7` zTynFw&Q;@enlxT6#*dW6f{-!EXPmO^TMV%@3ZVG2O*z1AH%PM#{rV7{C^QU!dRZm} zOQ)0)%%Nol+BN^_Ad*06GCprlYJqN(vx%8^*O^wbTD?iLfB!t+`;mIFJ9)A z6UhaY9T}5HoXs7--s~%n)`obQywk)xDSh|jhpax5emB(|8kLBBbadZ|eIIsJ96Gq7 zHT0(h_0-Z4#X%$B&t-nUGPn6Q0h#bq*f!C?ctT8FxDr-B zqq9}VrZsL{rXKo=O^vIZb4+y!6g0stBatvpgQG{%vx z;hOG`Su~_-49BBM3yJ9y@VN%gQyd87@p-K}rj{nK%1PY|Ualrt6pZY3W{}BfwLCGM z?ci$V%G8E~w3-;J)y;HX(vp!|QQBkLYklj7!QFe z8eHgZ@$wF4umH==TmH+P^|Lhl>s@BwKA>aY$KY3EfpYyng83`=*W#n5SJZ}#Bvj0d zjfMmgjrF^Fb`e)&BIxEJI*R&=2gNsdo?9ccP5<+zsdD}5W5#?>;;`Fwmi!TYePXDm z#lf`n?b#4-INfWRwh7sviE@p*kgG`*$nsGcaeT*Jc~1Ez^dbjq?-!R~{eYT)uwN-e zakYvET~MfC3~bJe9#D`2w{9q)s-eJDA-FfVD>O;pt#5(2GM~ah*nlD@S~lcTHQA#M z^MA6fuE zg^fvmx!c<@mr7Fl7Pnpzic$0~Q~E3TvzKESYAzP(XBA8|lnR!Bf-!?R;k;dFH z9fwaom#FK|<(@m7xO9HXuXCkK-7~Zp zDso>Q19+1EDn1?jx8^wgT`|oa)PWrPDp4@ZKtfeKshMjJtO_|l$0VSJ!d+;u=?sX5 z63cicUX^~t#e_0bh4PR9huCg*+sOAeWLrk*6Sv!#9* z`kyyCOU^BfM(`A^AI_IVtT#I#bMoVCMGM_)pjwESj{Fl_sDZr^)2sy6%T(-9DE zC5V|OXJjmYJgXxK-`l;ISPqZ3vZ@;hvByG_Kzd29h%5`>tD++L(0mi>?)VO*WvPGv zGc60G!F6O}wFV-gTu_wcFq+6QzO)=nK#VaG?C1%+uh?QGEq$RHfU*p#uVxm65WGok zryKxeo8h*7bVoQL2LkLg&$!!<&d&0N+Rr&nRC{;<@WbCgkoFRep);WfR96DUb>eA9 zUTkRTYNuyM$Po?f<&-yduJ+eYW@k;}*4>L{*g3^6yj2Cbz&LnS@YiZdv8~ByvfA^?_>J+4qxIRvm8!8RaD4E_0@J?Xu4T)cMme1U zav&LBf}$PiU9fJaSe+XyPY1nYNEG>!&N-PXfDS?kbmw|Cu4}XLC6pfu=jk!0Mh7rR zvtx7-nG#U^xMMQO3kAhwdU2=61-=8cv2i-SiGr$GF!rmmNFkJVVaCfrYL$p6PdI=A z;p-a?hj)B>qj;OxI09^-Pt*vBQcdaU*+?& zR~XLxU7pg)jZev@ZXsSE>HaA!1EfRDhjnYiHmlrnXHGX1sg9LRUn^C1(a=*Z)7Rlw z(W0#w`ughc6F6c9pGlMO84eqr6QWAu z5hnhaOvLTz3;;hjJK>uEE5sHa!Qay0=`qZwI zzlY{)#D;$ZuEHlsH~&XWcKE!QJ@uyPu4$sgD*P>5DZXJ>&z`2%%~>|lM-L9gN!Xhm z0KfbhApb>ir}qQn^*AjayJtGF=ia>c^ip-t`_d-JYyZ=WL1l#xQdH=0y;}wKhK)US z`B^5R0&rF?(kG2R2PNx98&aPMr99L)YE-O8!@|=J?`G1JOQmZR$p!$^bx|cf()_@? zwD3ym@nv(b$d+)j{-?yi_AxcLwB9Atr=AEdMwrF_2aEeHXqk(&2p47cR#T? z-5mbuK%C00n}>v&iZM|~HHO?ob$ao2fCqB)vAI&%=6#QA7KG^$Nz_YO0dJ?@Ok(`h zZa$HDcF9+=)0R4(T9c>Y9h8e89JoLQ8t4Xi#An@t%Qkd=dT_yGzo}22X9dI8k*wsS zD^n3n&xvV$z&rY0{L_0{TeHHcLB3+32;a8~oLTVBN}d6jup98kPfHGbnT!~E8}!HT zY{)MFHn+Q*;rZh?RHf#fnS+~@G9`Rm?rHB98;o#;Xe#PIUUB-yI1qoZ9n^0QD4!9} zhV9kgpCPxHkZD@t&hqkE>5uR*oFolYT*SHEge4t-a?*WaFF9Vzswhy7&_cJL zCvMdVm;Jt}pf0&>zR{l2E=meA|1k2|^y4dMZ309mf@6@1iSh}+Dv+GkcKq${6Oz1f zUf#VKwyo($7|rt<`|~W`T(v`e_=@LUr>d>cI1hNom#ozZUX@)-tue%ywcFJO;IV z7MFiNec(B5YR^l1sWTS`1ReL|idofB2g-mHM@6!Nfegc=mGm^8x_uKm%N={?{>IA& zz1?|l$bLN}-e2gcZJ{GYG4~_6(4Y*d8Q-N03;(F%W!SDD{+3NFp6Y(BxL{~d=&bc{ z?3QnShhY^nZxiJI+VdOwT0${9V)_K{$-uDNOD4V-fOx7U0AnDqA=Q8|)0?tACChD6~ousGVRB$&bQ7G;m*#Q$^PWEaD&;)p= zzcFc1`MKuvb)xf~d_hFUnroOgn7R~4!g7=g!LmC0o&h4#EEY1LiV<|y+xmxfOWov* z6^^z5fvir}n>_qwUZquqzsSrPdFcG$+iW;n4mM7CkzcX^T|4B)7}zt&!x23q_k8;! zS2EU8q;+)IMw+#`Scm#yBcsK- zd7Q37Ox4m*wv|}bbLi*NkZ0?w5N$;xPBxyPkHfZ0rbP!omk`82L7eZ^8s|a5fF8TqQoBV7&{hVmci` z+T=f&;`f%h?|T{0(^hcVjZo~&4oNY@RVtb0%}`3RN3IW?$dwFc8(8P2>vN!Pbil`K zm_6Fs#HAX;BPxTc^BH4?LGg3crC*ekEUfiR=bXKpi&tR$CFzuM^2jwFrqL&RE(mG! z+IrWdJN7>BU@!S%&_#nefo*;4qx?x-SE#D+Li_yD^h(1ZsN9Yu+JqjjD|teFS}XWA zmRZlL3&|&cyO7zoAdt%qaqREhvjLzU@Ef98gDjSQjc`x%^}^iUOh3!)f>aj)VSM8xniuljgJ0DMetfG=4kyHM*=lx^cvN-IDX$6bYdYyJkmG z=)GcpK*aenD8e?=wk?;}9|O^(H1yfPm%|G;%)oU`T`P#nQ5+a0^|B<22FE9L{QTyU zGtwdlttA#;uzY{M#apL39Kxvcx2jmmZ9=KMgj^5B_}MhCl6$Ggm^i-zQfNI zez+R!zxx`+CA)W*nuF)v;NWNgh@%}7_34#X61x)I|aVSQUpg(exF8TNnS#I=E;MR1h zt-qO+xnFPX3-+Iq#NWg(Gzem>zb$h7CgyCmzKJeGY9OG|yMrs?uruuEt;U50U@lvl z4D;i}O_XHD%VrvzDs9BNEFA#Zx6-WJLJ05} zX-z6sAAaW^GwAuKDpUU?8JJiL z%|vAXF1Al|^?(A8$4&4eQ$ERfq%b@>1oXlHZznc%26L{vhd0@KuWf*Sx?{;C+dEMx zKqm=#AzqPgn)~W6OY3VQ_~bdWd`&5xspMFhDRTGmColtlvzRznWn;u?zcfMA+!x&z zT{L0z;j>EJ!-8My>+59o2hzxMUf#^>PPZBA@Vg!?Q)S=g6ce_oweN(p{laK4WiEub z6!M5Xu(ZdK?+iv*yw_<2MPqKz#KdXOz9sv_zQE)I^gDj zkQShqrtb}V=lJUnjLJbFY-{LzU5gIje=n)SI3+)o;M499{OHjGZ~yl8h(jP%(8Bs0 z^7;(`Mdm{t-_Y><@LDgIc0g3P@s>cKie-n;HVv;>M<3N2TK*b8T{?Y*dOhmm?ty0@ z0;oK;B4?|2uAp;JWmM(l__RfT&@C2|f4Lj6nI)?k7GfGdce-ZodV{0qXe61~IFCLg zvqehA&gg68l6(_x8qC_m9itp>(TJHRp&(e0O;6k9Xc% z?et!Ouc1HEG@1S5{)sPan3vDp8a1A~4Me|RaSTypBxCpSPalR&bl|bK(R#{%J{sUB zX!WrbA=`Y;zD@j!Sm!Ah_SA=8OzWxrdr7{&xRNXKjTg^C!jNG`gF;$M+Q4i!Yn^_@ z@qj;m@Iq?EH;PzSEkJgu*Av{pKQ4LY=I#5)VRoI`TixI%Df(NUC_@|oMP=qPse;b`Cc)thh4m0(iS!{44wCyJ?(#zOuAV<>x^9xy+sbILIf_;nkQoZ+)G(Dlo#etpQpK)YE zx*yHpYki${7nIsZ4)s~kPl3qZ<1f$c-<>1ip-BRAZ8CXpV#ym&@bdE}gE;{-r>--< zen%Ga%WKza{kc&BUG4L>MNtrOjwI z&WnGeG|Oz8GHR4w*uJ+guNJg2EwWy}jJY`(M6a6cd;Q?M4?b)rW)DNV@B5kV;>l}70p zsDvOPN(l@QM7q1(;V4W>L^`G*k|RVql~Gb6F=8+s&FJ13?Dw7?&t2zupZk3O`(2-l ze|BAL?|8nR@pyLP2B9i7{)hShhV2u`qJHI*=f#qvP;&E4H=KlrOYH#9lkn*l|8ECs zu|+9dzxjCv=y`V70ANOS2Os+K(F9I_7qe~u(KeHG6mX5TJ`_BNDj*je*>{{z{C2Ox z+NK~&%4*Qs1W3wsXRvUEq5`>zdFmU2R8%u%{00>vl#WWKhQV?FQi{@a2MD>CK20#C z=wy-xroN@BDH8g+Z?LaFBiEfcVLP%qIW%>Cz@00UwT4i-4jqJpNhq8@(;$Dc8doGS023 zfx=W4L8Y-DE2>6T)QC1nfwMi6WS`q&pOUyaOlUl2@S`B%yB~go?CEpA5T_8h1gtWj zD5=IlRz}f4fZoA52xlTCRXft10?bP*9wCS1f&4V$i{=x~@0XCwZ<0Wn_M5u3Flrr$ zF3|{kTz@$v{MvACt?lmM5+n&*Iff?39&PKbDxmgrU(SN|SwtLg$ z#1A?CMzBah;4IsJJ@$t+#=ntNv=mTh+KabrzJ8~k3`I=1wwfwh!g6K$r0C%w;TVYw z&{_!BgyLabxiZC3$=pZgcN?#n`&Rz2CO^CSzY-X@1M^)dgB64h=6et62Py;g->GPg znrdxETlo2YZ^gPG+CVNeT5+(l{#|_jhY!#weph|;ZUh%QquX_5vtAWV`;oIWq-#hL zOqvsW3KSQpSQ-e_vPNA1NTcc-KhE_p6#E^O{rrQ)70QOQoxz&Oe$EDvm|g0&dwrsE zoe=gKD5TMqjf$)XCq;l8>^7wgTj`x>O2nss(vTIpNKKadfl(7VU*G0fruHeqHKs-C($c0uv)g ziULvl#T$WmgD#ON}d74%;4X zu3V^sSJqrN0qMYYhm19CX#wgKmIGM8LC6{WLqYuhs%dFheZM1xDbSn%Thh@8aLqT>d*ZXnJyH_^eNmDw0EuxD)QdX^?Nqd}Ln* zxz8CL)6OT^Qtqx-P#;5=vOI~muPMnDP7sLElwS{C_Q$p3G zi*nrstS-&0o2_t!Z!mI2nU!U&Io#=+29kW&w9GW0pgGCd@RAD*i;Z* z3`D+ zK+S_AI1S5$LV$pJP(X09@biDm?ER)4e~Jth3+22v`2UAF#1EL0hQi-;@M|L*hs zj}!$?r%8_t{^CUnQh!5q|NA8XDrJ#zTMB&QZ=WpyPWb^mo%S(tQ0w=A?ti@Re|Hnuj{#|YT zpBnl9ry7|QezvE!&AOvv%t1Dpk#GQXPVS@_f&PvGwct6*&Jmgha`QqCg}bQ83tRF6 z-63S46+s?uaw+yl+04Il*T2*DUlbJ39WQ^$fVdnkr~D#^kh7!@;T%3l!_4J95H)n`tS|Nv zrM&E@8TciH;TuKf0K_#H_0#A5x46=|7D$7OAS z@3sxnQU+gK%L0YaivgK;qg&AG!*SDleF5%=p7D~wTN2aiQRS8I=Tx#yu@ilnV<=@i z-7V;0K@ZPg=Lw*LVXx(r2y8bZTvv*9+r%1tfB8Ml+JH2e2U~aiag*`!Kr!BGb2v;m zoPZ;|%lqzIi~&#-t^M_T*10fQ3HI@7t{+b5?6^7O-Pq%S84-XFKI7=>s zu&(WgbEr;j32_GdT`Xd1yRR`*FNW;{=|oqFUcU%T|H)}-qKlN4#TJ1~IM;3BFyvx6 zY9NB>>WF@DKy-n)yR_HhsDUY3n?O{ev@z-|g%dPYO(pmhMtHVDz?6Ym7ihWJsS7uc z8or7h1NFxEnj=jCfoUR8?*^7M96bC!7UaJQP17X`pXi=uN3guex_jKvWsr6M*K)Us z;zg!YJJGxQ;l$+07D;ZfxI6g#nhD5I`O;B?TVJM_cb7p@k?p6#4T0c{nq`Jd^a?vNSVBJe2tnOhy0*REmV=)fc%Db5)ei1Zba-%Aev3a>$}Pb`A! zC)i_IN(u30nO`fpoY~fX2}eKj)kePT4(mgYDJQW|lP+NsIS++t!w^}&o zZ3Hi>zJ}xATxO1CIkDkiJ-slO#6rWtyr#$%4C*VMdf0~Aa?tzHNM+=5xbgD+dUNPn zItg6*4yD{xh&^r2Y{k*|bdn{diJO{L-lESZred74;#7N|vPCv!AlS&R@BJj(`ELA~ zfk9!sV(WT{E#!P`W8WMOr~%(~S3Zal`M2!YpT0#uud5RJHn^{(V5(PdKLq45XU|%? zOj04mkfS}pAjou{uAeiqv*!HZ4;OLhE)Wvf8sJ!|<~Y;Yspen&u)5El*4dGQf&Uq% zX=B;qnp^NeMV39wH(Ds& z{Kk9q$*rL!nG^v?+eQ{3xP6st`C9?mUy)*q^GDhHXL%dgw$yg)Oga{bk5}<8TIec{ zrAOfsRpo2+SAI+#5IswFKwCO3K!A7+3%h>UE`#Vy)H=kI%b^NWnwG4lbd>9^Sgz=& zw)|V7_fH?hCMO08%e>EHDtsboSVRIxEMSSci7WAA}V3Cf|Sj zmgP0Tn7?W&Eq$|6j@>b^OV4Sk0s>#3KkS-def^M)%EYDX;~K0(>lp@?^{oJ{f{s44 z9JNtRV=hGz1m73_SLN_s^D^K20v5Y|Ag@@^UO+zM7A5u;2*B3Bjbk6p0)Yv$ZSka9h-+$Y|}>pOQt^FZxXbW4&FK9j1) z3*(yJc%`7;{r;+x7gw{4T1_~YS>5!I5}^H$9+_WJdlLD7Rn;d^Zw63cb=o}Z6TU^p z1h`y>Kc2KYL&0Lw#6T~#jaym%5$#g3a7ZKGK$e1BBHB;EmfouA)k$K5myZ2;Kk~oo za%L+iZtLDy0(h(6IB2q}lu8ZF&naa+6L<0IHf;BvvU7V^ySZbCJ@OoA^0xEYh$`H5 zG;g$&QTRTYb7Q!I-@0~fxCkn<4rObO`lG1lpNRF{;G~7M??C+MW^hT0%|PP)9>_)& z=sYrS6X0sCo(e)=n!hMH zYvA~$=JV6u)`Vh%*%{Vy!!b{Xg)E1kv*du!tR;l^sufyyXvs8kti1@Y6|};lo3@L> zebfS7&h00TTBo7*4Bf8MEmXbYQ-L@WOuE-Q#e5q`>}+nnNXq~t_OmkCrfhsG5z@Rs z4ZR1bK;J!X)W!4UMDLt->|RVe-DyvkZWp}P|iO_(;&!nLWawfA17Qk{B(HZg45pR9Y)kDcP^ zY&qVhzRxQ4L8w>2kx;gXOQ*a!qH@BB`k(|2!Qw8@=|FSrJ zl;EV)dFph(@p@iN2*+AixI&^|Ty^Q>JdN9RR*$;DgXyj6A@uN8U3fRBQz%TOjY&k= z*|Vct)jY~*{>lm5{&>|qm#lMOadb_5{zs|{LL z0j)2ana3s8-TMcQB$kGw8{_t`tlNlgSY4Ge&^A6-xfAGodOzLz!c3gN)hCw&PR5BV z_q+=>ePPneM?ZrpKNsP&in${zv>^>JOoCr64&&5M5P47iJ3qAE{t{pMrWojEKQPE6 zE{T$}sK5X7Cm(pPeOUxY1iHcr>cu^$WH8avS2(3;5Xv1|j`*wA_%iG2SbGT#FjROJ zVDn3Q*Dvs0!ocGyeH2? zW-&}8##ol)YC=BIhusU&dNS%Q0m1mid+w?jcBDeN9dB+|%nD%3^i8Sy ziF79=(-p%e;tzRPJ5wc`Ft~Ce93S<5snuh|;4jA0p%C)<)_Fak-8)(cImXuf6wR?p z04jaZmW)G*le2fnz~^IiYK%MF6PXuZgx@f9|72e{K1r+w;m;t=3lP*Jnb_zIc>D%G z$*Hn@W^FpW@#(OXr9>xpNVJJoqQp5lMHtYZ2$VG#S)6Wo$r4CaTcJIvu;ia;57ZCn zf59C&Ru%9r$VRF5n}_sI%=%{x9U0LiQ~GQq99{QGSS0dkTZI7Ms_f3OIvy!cW)z6dpw06`_hfG9#`!f zbI`nFE~@g}I>ytrZu`*&kjBnyE2!WgGIOrTkc#$=kPUvNk?Tu5o5`EKZnvri*R+-h z_t)1ThLV$f-s(it+%CQ&3KWJ(BwcOB*K# zt6M>MT@Dd({M?>0QINCoX2-5BG7n^<92wBDg}I1*#_xP?7JvRgp0jbdOGo-U)-Z{9 zJ|toi0UL6SwyVpLn7TUnEAqO$yI;ycx0SCDh1X)>zU@EgVHq zQw_}5*|Qv+f5x7p5-5mm3irm3=lZH53&u{}FjGY6OsMF>t>BoPpwb02dff+9eN9Lz zWSln>GI5siRuF>EseUQIY#wJ0J8V8=^Qj>7-~@=&mlKqAxkKz!5D^xv{XKt6iv{1jTyZDsSYSD59lBEhOZ7YJUiNJJ4b+d+}&etB((_3U? z85*{XkuWGk(;UA;DNMp-1$dpCD3iMi{CRBe1%v72HqtyMHX1M<4{d}4GGr=k1yhw5 zzIMvy`GIA`w4oN`^qJIt1E0^^d)5oD;gRAdLAmDxx$^e+wXnnfqMwRlXGuxB z1;~=dn4b0;HC|e+$cyi)B3J=IW$a`bk!&F%3sD7(DgJr zP1W9s>Hft;@~pQEZBdPxN519jMBZcOK3o1ZKG-f~;pnxIzJ{oD1%YZEiS;}C40E`> z_x`MT{c_&^ucGV30hIAZNc7`uzFmw8>8Glo%b*EEV}o;YIZC*+`c5p0P_o6eLLGMZ~AVH_G(UfVc8AI zB5V)jeEL9?moq#GxrNKghS!|MGr?+l{7VMQ`}g>0&zXw8pViSY_hMT&9vkbw*8O#w zjZJ|Q@5is?+2g~S%lMh)tJj)H=S+wmyQ0k68)cM4Tc-iX{MXFJwkYutKl5tZC#o++ zD5N@+%@V3UCo$C6R|HsNF$^N=L=(%;jfeWDZz3ABvV+el3cD@GRuKC?2F!fL2({F% zmk|mqJMG@#-t?zR%CZyHy35OYt};~G7S@w)hxb?Nb5uD6isjq7u zRK->f5n|Ch*`H5u|H6B=P(ISi5RQ|`k(J!J`EJH!1Ai0sh%c=erNvl5dtmjZ!D%X% zVIpyP=I+aw6{?L(5TA)bK<0r^@WIO}_J!>~8r}CJ=4nBD6j;$N7$TAuI%#qI=vmV* zCg(^Vr}YDe2DdwY;XmZ>f~AD%@B}9&wQYYoX^&}b%70W$EySlAScTYwPhDe#>R&U5 zT&<}aWE$m2^6grmqK_$1daHlZ-)LQbQ9952x@?VBX0(G}{FuY(tO^yDmD~*$uYIZq zp*6vQz&Y23q$eq_eXVGyoVf;(3Qk6{Rq`b62b!@rinuMlMZabbzQm@Lx$8RSYZgo8 zqceaPAGUX9(jmILzL!<->4~9j1er0VCKxrOz$N>iah>gtXg|wJ#kMQ`Dxt6!(MFe+2ZPTTH_>UImRzMWp+p6cj=9xpK+%_rqc6 zi@1Sqru>)lQ0S zAUjhN`_Z9wF|e;yZbm$8VzH0S!QksMh^=!dCHS`Nu6|Zr1ouvpYA_-XG5_V2!_w>0 z{lihF*$c~lUR=?Bc0IrX7Nn{#V zQ+NnIa}nR~V5RH*^ivy_^jv9te-+qCsOYRQj!OUvWqEG<*aRqHvu|kc)!i#z)-b@4 zMA!C}A#p}3yk0w%=?u8O?N06Df#EfuxBz1=#XLR63kQXLBhek>#j)m>y zC4vHFbLG6F#?Cs`P4;*sEjcH}Z`&0bsi;$lug7UPX{&h&1r{h&#hY3cEvMl7CARxU ziJj^O?~HLp&kV?BCe-qh)uF=e}xkmAKW6C(Q}gaFewpWp^A>*t{=jJcL^F?`$X zmK0lbk6pMIfTmu)1ttjNd;P;^`*&)LGaG~~O7t73S$6<6mOZ@1?4CMD)r;E*U#!8oRGZmtsu)1mXeTBsSft7z``W27{5x8O5u-(cYZOFT30 z>>owF8@)KcYaG=xn|G*B1|zK`@LKXCE^|VzAi+8X(diyLG5Kcx>PADsusT;sIK`H# z0mmni%8!7zMoeW`xuq9tW>|2&K9!I5rm2lZw!#MS9BhYnif`XD%+rYSj?cYUlQjxX zQQ6@3LUH(lWe!q^K}X9}uFpPz2fwVWKMEf(zJI+syAsVK#eS@?ge~}%+%o)Fm4N-H~-yI$-f>TyH3l@e9*S(5$$=0^x$w@Nll}S(W z8^q&Zwi1`)(C=vIaoQ2H_KUS3vVVp1Aj>HC9Tga_gS~1(cNE^hPU{ zsOy&hLH1`6JW)8sfq^uQtTj zTF2hk&UlU|O)W>6Ey!YJrQVG9bkQDgtu3}jv!|hR&g0$g3P5bY&hW8{J#+=t5yfKp z)j$Qf>}(hcbF(TETg@%;xZr-lM8RR2}rG6e1ei?%g~gH-mW;GoI}ZQ{x29 z^;{xXOrMU;+AzLdN*x|zPoU~Qpx=ubX@gWOxqh(;qCt9`-ik?Opi1ykKsZgL`i5W+ z!w8S`8^fu_B%bG9uc_s!pcPL0w2@-6f5XCLPnCVZG++81_)yVMa6F?~vTqR94Q9hz zp>wjrgHIPXP%7u(dcSSMsjc2>%{!*|7lXr=Y%%u&m6p$=H1Jy){jbiM791*ldb2l? zd7IVDE*jFgJYM>;_4U?wEA9EZDCzyXV9XG6*gl8yhB$}SMWvTi6hPD4K_7d-qAsb- zeU@Fb?aWUVAM>3rg=Ia2p=SlgQGl7rq9)3-EW&eZ`4uGHijmqdHxo!!$YgddOHAOU zH7t^fA+E+?hcI8DPjeeJ(}Qh2=QSCt!|fc$?C3SVRDw$MV)TJ11O*0{Ov?B+*iNRO z9ohQq2B}mSUZ~l!Iu>`=-wYL5NlkAuJ7xUDb--BC%zCWY+K*9?t|0+8qanxrq`C)O zpj9q28#X%*9Gweo@0FpHAio$EhxLsbT@>Z?hOF5n=WU8gtpTdNB5&8TdWT0#TEas2 z2iP`?(yIcnzh*z)GWPrUdD4zpWiP8xk~N9p4(UY3F-)Fu9#qsbORu_ywSgJbdOVM@ z7fQOl{I!#i7jkn@k$NJDdFw{s%3H0V+jTaX!m=*q4!nL5Llo7?Cjzeo*ar=Rb=G}S zu$P208g?8u%}LMlLO$67xOZ~zeH~jiA!vLMJYKGp0TA)-+1%b|@bq=C=a1Cd9|mih zh$jN=51u?w1~RCcUpkX}_Zb3s55Q|*l~G&y_r&$aUXNL8+NR^!t@ zBV91G6;NH&%wDlyV%1WF_bLRJZDvOK7>nxL4%(O16|K&l{IVByZ7~*|O_mG3sM@de zWBR45F|tFo7HYvmcC*A)EX%ytTL0I3t;S_I1&h9k$<`ENowJ^bK44HzOS>G~`y723 z#kR8G?4_eQFj-zvnG?Eydh6?h6sI=NgBoRA>Ydzg*&u=E zQ`=6cpWJmh8zs_!ST2ETT0}NM9mt4VEKuMd_Qz-i@p=C=V%sEtA`KYZIMdtP|0ojZ za51BC>{-l!SLtUKArNu|1*x?tq;V0nKCq&gg};u+4Tdx}Hhk3yEFPK14P4NK>656Qg8G1_QB4o1F6I=wQYE*hX!&VD|ntuagVc+3@#r(l?qTTwbGEI-1KO z%O56AXV$kxNdWt)a`kh?imfDsDR@?sklDCvSqci(OycVf>Onj#hPrnd^|T5U#Mw}6 zEL3C3>H`fp4uLp(MRm0u?Eypzl}XS|8COx6_p=-t=DjHvA;mY%)jT(S+NMM%N;Ul@ z$$;n00SJt^j@t8FEOQWKE4U>R{>_+1HBh(iEHe+(%6`|^uqI%=a^4{~-p}4Ya$bML zdKTcL>pa=;o}0l^99d#O`@Lk6()g>wBEz8TmT+Z=OF#tjFwBQLIDxr*aO=IfR^*-B z%0LeNBdbiE47Xx%vR}=m*84Sr%CaUEbq*}qI*;|6-^%nVOG=q!VMt^}TcpE9MtyVM z>$knp|Kj7nZl2LfYdE*z!PaR!i36sd^tbs$zH}ZCqwAJYMC-2BF!$Icl@m7ErMd!{ z9;VBR0$d0hegOrSSe2OHJa`#x?VuHMg-zZCkzQD`pCy~1z=bEkdE=a|^;=R9+pFBT z8>H7JjIIW+reB-vPa+1rZ?5wqDBZ6z)bwpF z@kR}1pH=<_xRD{*Cud-6NAfA~OkRR<C0l8V+sO6^13aNSrzqV?~9_Mj2{O^4e}-swEXjN z9ma?v9P1l>sLeR!?VxblgK_IM7X~knQw@>h^=TwSd%B!Zcu{#=>-Lt= zISA%OAHS-D=S2w>=nXl-a?RcQZ3{QDR|ZRmSOAm|A@ogvWjRru78A+RK5Z{25{NN! z-$mGf!e)v^8zWwjK0^0XfQRFI)_5wzjojO~_l3Yl0kkk!ctLA;cj95S-1)Z4LomYg z9sW-ydFdiFn7FD14FQRSL9j7`y6NFa&&Qr<-0)XQxpl1G8Zv8+surE!7g<>up4LfFXqbE3wsGK^B0(RQvZtyz8|JoI!>npo zTJ%*QJo-n-m#EOWSL@{L1EMH&1`jxW2U^-29JBe3_DJN)dm%9%J5 zwYRoY-Yd~UHBT?d86=v~e{*1t?BlP5+iWa&6iG9_GlDiR03mI zoQG&i5-5c@`CA*7OPB?$!|7fJS0eChrwvVj_w ziO^_VbmUe{lE|qk)cPis!-#LZlOn2=_>s+Te&mE6^s5>}CF`&?qvRkPO~EJc(}?J; zK^C^4i`8~ad{W*564qp*Y9>1lF5@Khiu)UNOx@$l9nwh;sTNJE2bW6=nLC2TR(w!CKhYaZ-~F%3&~ z5jJw{;rLTO+bs0q(pD3c_R2F~z#d&)vM*muxmCIpQBW|q5eBPyxG4auso33sVYuIX zBdF(`EGZg3rTt{F0LL8Te{ib0_Ei0-Di|Q=>UVN#Cr)!Vww;pNkTJh?7@Nj%p?NP$ ziIFLNHm3Jr^UI!}5ynC+Vtew$$`oO~c!8WaprnHJgyW z{r*znJLANm>EF-Jp6 z0K;9W<3i{EUFYUAFU!IVdD(|c+rs!ro66&Mj^zeY*OGS(P<2tG3+JC0tB zm<3}7LD^7mdA#Xz@p|-Th`SX>NG#f#1@Cre$Jx=--#rWng+_>c+S;>= z#n3&G;h+7eq0b7=tI{RplfHi5qRSAJH7WRIVlP8zlH9U9qjVv}wh*JEm>l|w`RBb~ zcj<2)#+H3Xh`fUTf)GAIpH!{HsG%UZb`Y&#egR*EMZVWM`;{=P6&lLEj%Rs0F@3sN zyKs6ZWQp_?#>agivetv_W{7i+kepEO3K_=H@|6~sZ3}3F)Z2^+Ne8^q;sfROL{JT(sx~-_b9BEhT*(UdL^icm+>ntCM_U8w|Q;8yW}#( z*1V!Zs7!9Dm3?@}=bEWQt$}P+U{)O0^D%V$Eun^QU7XMyBqBJD4;hqLiDAiPV zVj#T)uHX_1jMVyBhMqTWaYlN8TBW;W`f9yclP1_cPiW^Be3{yyk)0%5F!6cB;;5*OMTK@;29qADVL~#9 zEvFk7jKD7QlXAyAC!*^%9Vux$mW^AUc+GwYF`2w=yl;?Lu7iM61O+H2&1P*vPcECE zCSC0f6_*5FlQn3wuXq%geZ^4*jK>oA^hfyYcir<+kpj>1MbSpGL9|*iY(Vok{cdPe z@A_m5j89T&VHE4ZqwOMSlCey@$@jv|jj$?s?~Q z;m(Howo^G`Y7Uo`(y~JDUrMLYFZ7v?&W^ZqK}wofA2;kJnmnr}<=(G3NL5gyfTxX0 zUyMg*?q+;qZX6&U50t)<=MfMCy$Ipy-BAw=x}dn&uXS(DEGixs=<J3fRk;iv`%HxE<}*!_$;F{g(}|GW9Q1O5lskg$ulEdIU_XtVkxZ0BDBC zN5|YUeO($#Bk8`8l20czN~9iGlM!4?YNf}~KAAjwVdBEr^2pqVQ%CyeAENU_fqzRD z&>&)Y6-zs$8Be3N2<82l7e#WD4`a#X!ZS((i{ij3wHMeET$B)Uf(}0=m|%(0Q@Y-p z0aFw@?>~`ESxOV??e%f)Da$BEk$AK{Ww_UpOTp5B#HU?tfy?@LQ4EXsFCj#I#;SI)IsXJ@8MPX-msH=1D^ zsn!b=5Ie;Ik_NK;)m$uodBMj$_sxu_c0z+$&y@l1*u;7~4cIuWwo3-x+vm^y^DFJ1wZ{R=QnvF0SOe;0 zR;s+*^o5LsF`LfqFBA-m4rVnAgp#WwlLc;zvO1w+31-JxN5vw5{D`3jMx18Hws7^$ zRD~pvW26USE!PyIBkuvnXrX;{J*+(jRr#u?!}a9vgK~e@od5Iz3Oc%8*!{>qM=%_A0fccy>%V=d2*bkIx0g};ZU!JeA$uAB9;1pwzpe1 zMLziAo^VU_y!VcMk35;-!^j3(Y5iSfB;esL$9ltJ;KOS%Z?O$6My}?!DwK3DZ)9DJ9 zRJ?KJ_t8dw=o{7?U8l^l;a+wF=Ewy$II#HvdVTZu%jG(hee+q%kXNN{d#DpU0`QYZX0>>oxBq`-(87ij?7Oq8@1(J08Kzvc(biUpz6%v7QIi zb!{FZ`?$URJK2nvN+_XLzN(01AFseRb{!3J#Gy<(#C=HaUE`^Xt~Vc!5ZYA6`&PMb zP@{Qq<;%bBJh@m|If|v;T-&#Bi+K`D#=Z@d!G@UV8o;Cj*_iZ|SLgqDz5MrBEHp~s z*@tGLoeS>BoxojaRDs0|c`!W`*ecAX@LG-0h6u45XW`q)P&Ko!U1~r>OMKLV>&fRO zOJbiml>bW;N_@Tl=2Dur35s&C{0%0hm3B+U3+e|r3dwb-7Ez66#3b;JImt!Yhuq$v zy(Ql?xwN94Go)}S{VZ4KLTCNHw5nP}QMhz(i)f?;yoA?IOVEQ zG<(_v2d!|W*a+;Q3!VRNp?zamdhFgfI4SnmkDLP@poUu8n1i`>ta-6AKcwp2s5as? zE0p~NYie~}qZpiq*Jn&MSX7_1TO3kITM@%2wy0P`Rk;=Dc9gIiTp~?S1zK*PlxL)Y z1w1;#K}xdH`NnX*^SwtU0LGRiaZ4MW60$!>*Zcf z_0_}L!M5xxal>mmaU|Wgx9>N1;&vA;uJrC`qO)S*s`izh#y+(H5TKA@bf>PeCfci{ zJKxB|wXs-ReI|=mzoSwdo@7lKx(7odQD;^BX`VYi^%n3 zXDOt*v>TGD2yfH(Z?Tic-_Dl#8%Y5(=GqMc%mu2iFUPZW9=rmg1Dn_8UB}9MD|;qg zc-iG!wY|F~sd$COS}U>~??^qZ*0c&Wmv9%z2BHxH-PS^#KsK-FLsR*pgX>t(^I;bN zowjjL04@kZt4T(&4vuPBH7G!gSXbMwxD|AJ+)oftGEKTs{)%c;ztE7Qskidw6~sqk z%Et=2uQakk&Ag&7ngYrUa9%-SQ-%Rrl~2#;eDrqVuUa?O%poH~x2M%Cf9%k+qj4+OV;qS_k%l^yOn?7ei0<1_*ts z+zB<~5R4;x#q!jSfTx>?&zgbU4fhR;TnXEdR<-(>lKsR4B`=PAdf3cuAuq@5Vo1(j zaYI+9JWxS$3F`9josWj+I3wcee7xsDh(@?3BOLUq810p`Y<0=jC!I2{Xvo=69uZ;H05p^K7#WSE1WfJBninR_s}mZn#lPY$0B!Z>`d2BdsZu zCXk@AIX(eki4pIS~r%EfJxBcPTF%J7H5K`l{A?1a(^K z-u-kprlrJFis`!>(X0AmYP1mjadrGO$M5smi2_z zX!QypgS>^=dKLJ5Z-@zK8ngr za3I)C$x=vy&Wo>wiEsPXyHT3wtgXR7r9cYqox97eww3ug;?s^0;WZSjMz>emsnSue zIP1n~ghy48da#n^Fcwvqf9m3wG!6>8$NF``22JPO`ZycfqNH0|jDmz*S|h}6WGzN0 z)k6ovLf>yQshir1%4E+%IEZcIkf7r(j;6=*r^-B9Qofn;G&1Ogg|4f*T*l@mg^ONX|xpA5 zm|G8n6@BPGzvFSbnOv1uvXO%H4@uQtkS-r>d0y!71V`wVpa%Bm z3~QEwnHQx*$N6vidty%{U(uIpb+MmiE1Q0Iq7qBiOdVs|5~@v@3kW0i5cpDjNB+xaL_*i5q+DQ2u0 z$h!kuog38;Zu^%36EDXFdy8Y?TWZJ1B@%xcT7fQA$nl2UlDiUeW6y}1PVsqTSr8q$ z-R7+FmyvU`Os9*<=?5`$`A+z|Gi|f8y{Em>o%RcJaU(scRTh>z%8YMKC3I0zbp096 z1u<@;7e{ib2NtS4%WIz0CKv*O>0qF%#})<`Y01OiYq3w%*1M(~MVf%g@ELL0*@rd*le|(iE)#m?p*^6iTGN&Tohv2- zX*+X|i&L7VF9t{P&}lC>Az@M0aT-g{qnBm#dJ?uaKFT*7yqz6mKe)l(m3e6G z8n@A1$k9$OPyu)=<2`W23^TVdHt(wI2haQ;%TEX>_QF+ad3%8MN-jS=Q6=-EJzdJ$ zT^M9=D!j29(=3_uX*B>yI6OsvT@CAi4<>e`05ygz+F#nceBsClhCMzYk=CxkXn5}7 zwEmEcP-lUy{g~JDf8Bfn4-jkCld=+jbS;$(vStI9KMbxXq?@{PNJ- z^phLQcIz2mlQ;VuFv4m>-QY&e{uV|^M|*cIsNWo7DnGxS;JNz?@=ID#I@;>Ah=yjt zpj4rJ;LDAa$sy6#f+)8}3vBiuG7T{4|mj^pb!*FXy>N(q@KwQVViFkYa2 z>G(1Si0=+7h4t!@)n5`t8&H>Y;vAkS!BULx{=h#=lFkh8u!QP=>Cx-F#B;9lgsq)76&lfl^yN8|F8d;dB;^ugsi=#(ub)oUwQP>P)gClKEF7tAR#~4 zJn43z(dkjaqYJ6WjWHKCm^xPK#UBMo9lJxa-E#v%<6_^OO|j^-d%v$H`A6`F`Obxw zus!QR!uIAXE+6c$0`89@zN(JF@$<*Mv^~V7dT_OV`J6vF0H7&faGo6N}hhd<|}K4_I)iK7_M$?}`ADZ8A`= z1@*&N+4o~h!xX(83HrXDfhyATC`Tcq8_RVg+fgtY#H*M=Nd-{>Vom91?T&f{x4hQ3 z<*yfn!#Fh1C4C*eadIWGiJYQZKox>)@rP!DT2K(fM+@BOLsx~~JppnR!U4O`)gKM` z|De?$hJ2fVsQY-SSyC>J5jS(uYp1EE^Rw~T%IEhWSWSlpqn3iAZuPx#od;?X@Br@o z-Q`WBG=kd3>_x7Oo;fK!>0yArEU65+#Nfr|Grnq#HbA8}O0Ffs4L6f%p!iwUUgAKD`JOXmB%+cq)S3a~R9 z<4oHK_4X3PnW7bS8xv!+(&Cf6o~9MEHAGl<{YQXO{%x*2YKvSJ96s2G#0COivHMWR>$fVvcW3Y;{@ zKTzo6k{7~ld-UPu)&GyZw+@SPYyZW!2ofp{qNGYlm(ryY(jwg@-QD1*NC_xNNeU9u zNH^*TA~AG#4Lx)ZoCn``zng*W?EUWVIoI!ZuH!#mIP=VU?seavJJ(wGYF*4e)K^5S zZPFy7D#4nTmp)`}Qr&R9|D(ippW?yso)~1V?{}|QM~3OiY3OD!@dWH0Q2RjZj$H(p zh5)d&5gE?n$+-=AH%b|dKUh3n7$||Te2rRL?*hA*Ft~aSKGLEVh9#7_CkJA(CiZa_ z&=**-Y_4+$!02kq2w~daryH6zf+yk9b~Oi87Re!EM6@~vbI`Brg##sT{VRxzLq56S zCrWz?t$qNn2AGw)QNqa6FSoV(lvi2M&$|xuD&(azvVIj?FVx5>y`QWjp-inI=BF#y zb*J8m$wc9nbK+vULC;b_zy8sj^zD-FRPI4di+XWWlJ}AmwV73nD!$j)0%*l@2u9kN^$nTtJ25KpuWp@w$;O z5V*c7TdDg_`F%YT*eiI>U}^bNz)b>GnM);mdPh~@oA~Io^IWrEJnLA4JZQ}2ROKCu zDUCkeuOzl!@$@2<%F~3A(6OXki=F))<3ABFD(S3&I|FuZ^OXeZohZecI{ZIWl zCad1ITY~OD#`rsMk^iwyZEJo(vE8ran?wKG^K3ztq(GDlY;mKz1B=-EVDYvx*FwjP zzlr!hP(&HATdN>}

)U+cb)23UkZJd!1m;LJ$<6s851Mm(L$`;0_AiTBtJi-_*$J zVlIG%WehTu2I5-83gWZMR8g%82UL6+mB+7@Q(7HKq+yB581(CfFjCKx5?;`Rqp*fD z;;=-otY7PG0YX7@nVb@rFy##8Q8xR>KxNR%fsZAWHLQ=(E+#c`f7)dDnpdg{SE+p0 zVX*_2lAUoDTrrF&t2oxgH#DcD<7E_NGENBp83SQW9|N&+k(LW@w=vxqd`!Nl&|P?^ zlR^+#)!`>#T)36=n;rS;l6wn^vZqTgm>*wT%)dBXHo$^aOpr3QyVPKad36#D>_9IJ zTJnx5Gn*g?VBlIbhqgY|WESh?`!Xf`Dq=Y%Bl%hm^Gd|~tTiv_)`W7QG9jOh6$RAM z_!YLafHhHesj(Z;m^o_Xaek$@dm!*P4b(|FFWkt7S1fkRVamuQVa?7jdr8RVHVJQS zbP815HRy54+ZWqXM+?uB0c@~JR;Bz1{kt7oqj6^SgTM$?^|I7;zV!PqUS;z?F-O%n z)=<+H#UGXOKdWT@TyvXcM9xh#XDpcpqEO0?s!EZ?@fhCQ-*w#EZ2}+3Wz;HEe&A;n zx~<@H|6nA8G%x?Khsi^8VlM!lhITq4*L@iK+u}%X;nmj3xEiuao4m1uXH5IeSWV0~ z0Ec*9;y;}b{w~si<}k5OP{2+prIQ#3?oY9tuGG@e=Ek@>O+ zM3(0AqsiG*G;OPf3coJXn4k!7p_7w;*(uj7&MKwFqTmI0F}RjvWM}WZF=)1Esh*ev zPl%azO0ShHdizBif1fVo0a53@oDRMGO>aC(VHxpWim1#~e1O%}H8>iKC0{Oio zcUB*DX`CWcz&hU-N!YJtF#Sd6XN9I(}xdF7jJ@0(ytAc-Gu%it@P@(N| z31ipI(u8+|z;500QQ+DK@SeQ{^4t;=vrBCE8b}CB32#Iq%3$}PHHwbN$?t=PNxMey z{`C20BfJnuVn*`!QJOTb(oc)`ZnE70RcM-Id0lU;A*3vsq2m?=)c~8}$*68nP<;oQ$4W+b!qwk zt`T(`2K*eSjt?*W0|L{I?PFkB#j(Kxy+h+NYEd~if!SE=F{_jPkGi^3v9X^+^Cw8@ z`q3*f`|j9<&hJ>V9 zyVNFS8?CJVH;uSBTCmafuS3lLJ%x3bP@UsG2`xVT!68S?#YnVL3#J2e`%%-W{jfcK z+O-5wqo>(ec{)0A^GfDVQv>5>;Ak2%F z!sCaI!0j5q<2?+vwtPHBIq`kR$7Fs+$IrjgvjTA0J7Z#T{M+d4x`obR2fD%3kyAYS zn?u**_1VP*Ti*Xc+6zJ+uWy?*QJXriUGGk0UoR~{zU}{K#Mcp`5mL$Fss89fl|LM1 zXd4RlMQ6(Vwz31B0q5b5${iQU5qR|X$aSCwKp%qr;<$rRQaXyw!dw;7%^^SKVw(kM z$wQF;c&+KD&-~?A_4vRc1LD}=e>9bUF+iji;MCUs&f9C0Po9y6i-Ws-()|0U3sN(pK=u(whVkWO zFn-Eq0AB%1J0oF-Fy3jeStNOHk_5`&wkb;*vV;4_k&6|GpDt%7-|D9HPvaP>w(Cj1 z6`U9UQ3Ct9bOiDG*wB&}HU&jG24^^<3ofk<8qY>77oH90pQzM2{xE|5VsnL6h|PUJE;ZVa(FywD#* z_VaYRt_z?_E?A`jDN(r57eRtE280)Wl%M|mQQ+tdu=95L)m65izwH<&;`n{HI|fvc zEnegUd4KzdGok8V%ogFbarXh3@;;#cITHSS76>mz^KV5M6w%KA^;ctL081cuZy@0s z!u$R-X}u-li2dKb7y-;s+=owGvGFngbo9SDGEfS4Kp6CIa1Y^qJbplh+TqoMzxlpD ze-{>R{Q3X(#V0{i#|wZx;hE^a%9Q_*ls>$3eeOJbSQM2ECO=#hW|v^xFStphVDu2MYnqsu-wNaDJ9lwB1g0qFbx+j2u&f&@<`kB*) zJzoz+nQ3E(sNQow#ONsO;68sOv%JrB`GgVy_L)fr;|k4*+yGKKm3@!RrH`#8&QcU) z3BW>Hcz_x4bAg+17|=7xCtQ>$!?BpbG{c~j!Lq#hW8#%>lU7TdZY#}Jz~;utYR5U~ z#|>v#Q+O1!yv)k#bVu(xDIBh9Z(}w^_j<3%@ey=T&qEm>MkW)oQ#G?b3m&C)mVMa~ z()vA5vPuA0XT3eh1j$l>+T~i>`hkZDTx66A#`ALmpXo-s0{bA+N#6ks9Cl7@1nC@B zT8@Nz5RnK`ulj=%ZeA+E!XHh@*iO7j;AItLFNZVf1J8Dh0>=|X>FPgY5GA+N49nP_ zI(lG9p;aaVS z3I+r11|8Q9fnkr?Rj=!Up5?G4m10?BIh-}K$_Vsyah04H#rLXy7CmajTPAI_;?iGo zPzBGf+!f@vs}+TZkQQqTkP6t-3q>Y|KJesjk18>RZb&U}@@EIWvnlV8%3{jeUfJ_h zfMF`X0d_JZlf%$Wnpx$GMy@P44ep^leSZhRMjVF?-%$qhs`?(C{`@o!*-GlF3yrj^ zj;6poUnz6QVewO>1h5BM*~On<+AC87+xX;xZ4)YtLd^_NQzw_4yo8(ke*A39Bj4n2 zbTgo4d)vg3Ot-qEyi8s0;O(2Yc1t9Y!Cv)j$X*WwqvMy#76meeQGDnwCg@fjc3X?N zX~nx6L1(9PnKP5GRwt2%gWY;np9^l7b#I7ai2jRNyoAqCKcB!#r6qY=Z&_3IN4N=r z@~b-m+ljHMiFtH~PWRxcu})TAyy*+uvP9k81?tkP<7>|ppiTD)M&*>)Cz6X8ik29J z4zweO1y|d}VqV&v-T}8x1JuH6Lj2m1o2?`q6lFpcv~x~TpBXaVif8ff!fleV8jOc? zUz2vxJp=SRoZn7yI()L(mcRD+c%-rIGSnLuhF3#?c5@cp7>z73plRX zh4wLj`Qgs12o;UVk90=+s&vk3hQgDULXGR@sqiCpVio7oP&prYxhn30rsw+!$}V1f>Dw3s>G+}2N_KniHPrm1<;s&m$qFfS`ORP(J^QW z+hVFWtip*)XO0iV|9E%YqW`YpU@UDfgMWrwXa`F~#KN+>s=84M!N-IBfJ9pFFdn`F zkL1?$z$R*Esq8L_XkRCZCJ&lLK?M!Hf5Y<4oqfraxfU@?LyX)Ue- zjY>BaR~Nwc&wE)YXe74INuyRT&y)^8pYw6mN_sM{W{JXC82zMa0p9?HKq3Z&AM3~8 z+$>LPjdHVaqNusvzUNhSXBSSesV;eDMdT%Oeg`FS{c_ifzwoqdoS=$~19Zm4B^C9a ziUsZA_!WLe%Tg-^B8SU^^T~(Yw3)V9S<0&o3-~Z8vKX}wt|@`7)=_0X6RZR|DqkPN zWQMp_1A4FsnzgyTym;7`wQ*_(Nt%4sdEa(rFGE+PgE8CUpsp`ieiJu6Od}X?)50 zJi;K9y_?rHPv+w=;T6EpH)mdHe3yLmJbv$0H8Y5Sk9hJjVX1!$>|;cz2EhF9n$5;$ z?$xY5uE*QFe?c&1I4Tf*gk3D4g7JxY? zm)ysfmpr)5E|bNdS8Yk3$u<@fz<_<#QrYs6uUxOp!RXj$Ou@Y5=TUSTU;S!j5BvM%Jsf zhB?nSC}|BQa0{19vxAMQps)Fcx$F5`+R!;^0#>QTaMNNxy4^+aOwVV`=`<&u;uoq8 z+8=gS7}XD-i^hk3DdK~b!Vk;6C@A0bx%9ulX__<}U>@uSQ|@VRrH`6?Ib@05xl5Ar z1>Goi0mo9+w@6-A>Gc}hl85@7TL6@9Im*>N8OvOADN^##E7@DV*<+!T7yeyOx8(dm z^OVJIEp>@wHfQvqC%#Wand3wq7*3G0)hkKyc1OfwoNvndtyfRf!^N%wm^+S#R|ALH zY0p#l0EpowCO#rN=Xx9uI$|j4>kY4FcgxAG2?e;65@W}Bc=Tl53%ifMZE5LHGd16; zn)9@@e*R`UMHute)xLSC+u>(0Bfe8?3VcXzI_?#D0BjLww}?~8YYD8R(TU~pNe8gp%bJ8Bf<3_iR)^6YTKwvuwj zt{Ud4uWtRuZ&M>PX}}l6!uSyRp}!&x9{BFY?e% zWoHl^3iw%#oDtUCw>-tgwlB&TOmtzAiyZ}!2AZR_5ToOkswYG6-$1X?=6$xzPk($m zq}F3b;eR|?(ImHUZ^tmKR^5R4t6%@Y_EOQo_DBH3o<(siUAd3NCscbU?o_L#41qEq zgFI0p>tM~_qGWwWg^HFw)j=3U;*=`kugH~G>)&gTZ>x}|0_~jyHK58wUOjO==DIFT znU#ld>UQ%`nYGV3XawJ7*Q*A&!0R)2hnEVvGr!HR2GG&YuWD!#VZow!+;hltI=8%| zatIEdyXkBCaIimqlvNeIly@hZw=R=Sv+}fs?;jNGUpT&<_prwZ4w!d7U)sq}M2Q*`R zAr1sXy#WyJ$l%z4x0e@Mg`A*?DjqsvmBqx_?4@RA`*|}^UD`uNyS+!&Il|s?*fpKA zQ)!LLtKX*HC zn83P=?gaGycYh1*E?E_%#bK#lYOI5#L)}#GLh3T_3&W*)vMb;8SU9y0oafN1Z6aRE zi1(^$m^LG#JDOjdyCax&Z`zM~o+UBIIg8q6Sc46{97tBs>kE}}bzo?l6x+q%_X9rf zQy88AyjjUI__k@fNujN)@z5p4s`y!$r^=r(i}`qyM6!wIY3)4~H{JqOC_cq8> zmQ%c(QO9^-x`rm*|+Tb?}Llb4qgf}%Z`Im|Zo@qAn ze%qXm6S84~4UX?NPt|r6STfJ08&7_!EI;HbhtkeNvj-yzzu?f#7v9ZM9v=q*Z68l- z6Q~%=vbG1?5@|11fH$NXV=rQWh7Ot_C57&Dn|e}K4tqKL*GmS9ujTRX#wb@uLpQ?%7u{2a4mNd^&kQ6SlHi&gTlCaGkW9!T}pakSw61;+l4&OG~LCa5L9P^^-xBZeJ7|N^C0f zhj09zvw9}K!SQ@GagkkiJx0Y_g_}~>sXCS0W_-b&vv{qrqdBX(#yTS)%{d(Z%^Sk} z+ju#iukd^&R+G_g*tXuqBU~(_f4t=eU=}i9GEu3%G)7>?Hv`~rOqH3+glN}ENGg6aLtWa zOy!j6Zx};nw|N@&St0Gt9cihkU`tItS=$aF>44`jN;A*;Z~LD6&+l1wv~AZ1qhGPk z&B?P}=(u;JbahuHVy9}HD=`aQg?77Lsw#Lz*~)R?UZPNwhKTmPC{pAkh#jVJ3lp>3MzKK?hfBU(>(o?{_?Qr~tA-n1IyGKhlM>6Z z8&+mDl_{(J;m-NseU5iduTxu~nUPQWpzq_=w_8p6v+ciFsqPD9dW|w`WCG$?GTEgg z4z5UA#SXR;BHXNGoQx-0H4$x5^f+_dVck7cYATlJ{ZxNQ=TxBe1F5s|B8OfM^z>uE zr9ewhRW+sW-D1EvF9dtZXrtmZbr zpRKqrM&RQLUv<%TE96HTAk3h-7e6|a#MoB~`A9TWge_CP<&eJRRr(EZXE8!lV{0a3 zrj1ieJ|vg7#^5Dt_Y)pz?W6xDMAn-?yg8_Fb`qaa*MxcKkxv0wOw#~Wia*mNzNU>= zAahUaad|dJ-+6{r7C&iBe+%|;2?*O<#~jcEXeH-$4ae*8XEzHx`K3`NuD9pD*-_U} z)qn8r1?t-N-H@z}&0;Cf4E;o-`@_4tJ=*(1dkMQ0R_~n>6XragKcKxH5-E_wCEv58 z$je6T;NC^R81!9{JCV$3!^CykS^qRTL>y4?abpX+{&hR~DTV)IPA{l!)r<;8Gr)tF zdgXd5tzw^*bh#wRsY#77#WR%k6G~w|H&MA3F@Y24k$%Thw5Y$9?FA|rMJni>4E9J~ zn-A@xma;z%?^L1Xw@Tst0SjRlYfg`-;9|gPX|HS5C*HX>m~>BK*A1SV5lXMs^pLjd zP^3#>SK(i`zyCo829uTu%Eaf!%gA?JAD2$TP|PK-TCr3_`r9sWp<8``StO5!676|A zUgu(YgLw;wqFi6T$_FTe!k5%n4K{9n5O8BBph%-n6>ZFuK))i>DS2^wMuRqo`BLo) z3r^0<=uyU9vRmAkJ4}_0pR*d#%!L=sM?J!~1&-nYb+t$+PTXH#V6O2y=--IUkSFJK zOknZch+behQvlK+>KibJA-$IYSWp4gV0DG<=jwcZnwR-fU0N$+Xe7oqf!MXVvASEjv5?4b-aIpgyCB3sDjW1PbzGYaLfq+p^p= z>QScJwJ%z|J@Pi4%lKox`!I1H=O@DlsauP%Yxtj^TWqRxU4JvUh1XXOsf-;N;?S9a zwr~)5?Qh87j2AA-u2vmH#ZtVR$U5MSq@$h@X0p{@#lCw6PC)>iG{N0>PP-VywGelv zhtjsDjO~_WMpn(wlG{NFs!LIu!XysfR{EdZD#qSp`pe%c5-!9YB;mU`@{*QHh}HmI z6uwnY%9s}#=t_GoBvK~B@}p%yE5+5#7==WNV&?%>t!tL$-Fm7*@Xub6vNmaA+El`c z9J{>oh1x1}0L-LCv?8rKClAdXcM1ud-D}HWP71ng<8bRU)uY?Aua4TK&p_{VFwlwB zg-@2Yl)kjD%I-GK30f;gk)frMd$cz7rGdzLi0NE`qacA9N54&&z5RHa02&hkLo9=Q zaL6I=yhQS$l2W4|=;5ubouMm{1+QK~KKioZn7=aeIoh0y(J3*C#aWh4UO(cU{tXhk zpU7cET6)G^%@DtO!o&Rz`_D|~ABxQO0F=Y{ab7tFD&DHmF_JQoCV7}3EYD6RE7JJ9 zUfzoA{mR}t-k^@ZZ~-o(FNHQnPZ<5h(vZu;Lb)@Nf$0*}>wWy%G9a0I%CrP(5fFc1 zpvUv@8jJo>pRBcP84ie%X36gRL#@>H#DJaqDI*Wdz0JX}hvl>~h$6?pH5JnfJ9^dm zy^fCt`GO%Rg1F3G%-E+pu-U=!ZH(o1_G>*_A|bYUI*ExC8)?jEl>V_C1$Kq@;zKJ> zU&nr|)kp@{Yu4IK##ZM!kgrD?CO>mXp6P9o%3WntU=4IP>e5zl5w8}Sr=H%QOoJs% z)rX8a$TmQGQ|ES>7CNjq41q2$hYOvY)s{_S!moGNw_Uce_cC|(ntU;*8f#(xZ;x($ z3ie&Ncft<+00XEVjhc!}F7y6rH9(A`F5OF#4)C%dTD6f7AZSx5I53iNcj;;eV$nvM zLR2svA+e|Nbrq~>uM|vwmw>+NWbb!fdNzy8tD7Od3+uF0BP@FYc^%t(wo&y#+v4!` z3^Pth0_C-({Z~)=fas8F$#E`BnNqlkuX>kR*18|k^|4{}8y}2wIJMYPOO-TFmn6Q% z?S4aEK<_T+(Q8tJ1y)K5R&V-w#-8&vrz`ZR1B4{-pg^vM4&-g1ftG#}W26zOWLg2G zS962aV+~^+%raq&$tf4H;*B^ym)Xl`k;x`ivKW|zS`>|I(C$wMfKq7W?~Ywta)BtyiW%@HL5|ku z*;gbaYSrf!8S#!9zZV}=*Sd%bR@rMuBSH+=$PxVn6s~ud+ zM7u>AE3>>&cIXk|5fZYa$@pif7UlyH%a@NNPAeCp2O)V=Csr`f(df{`69CN*JP=ly z$ilCoqScV1ZUk;T>?i&dj&&tXRz!etuTQW8;=Oukce7x#bUXA)>f<;!eBAXn4ILi> zd1o#rM{2FTU86+Vt#&g*OWA@yDzDxKf~o4!2^C^BAef3$5s%I76cX&5D^Jfqckk3 zt_`Q7XX<+G2e2xQr=iTo;^)T}nEo;OEj%;;`13A~z1)W2z=%=iwKjrWY0K>pA=05i>D7HH#hT zCVcWD2T&AK+Un6%!D;@3Xo29I>smzNCJEL4a4B6>`Z2(@5s3t{XY_53Oth1ncM!H=}BXU(sz015>YVTn&4#=XoSm6Dg zYHR0%^5`D=h!5nrt1e=K@Bc&#*rEU}DBrn#MmUHHorDG`bn){#uKYD{ZN~@UUQq%0 zBhAgVcb`#uZm94%*_y zmE8^Aa%gH7kr)MZc(z`KH%Cm+M$=FSC$~9g%S*Mqy4V&7%QZNhtZ+2$FNrkNk~?I? zqj;8#-r_du11t_#2;cTZ54{Ur(4IdHx2s#XW#mBtT{(UilWK|&81dm33nlX4Y zz#paqM%AF0{5X5W>B}xIPhO=v4wQ3Sm9m|!OKeirFa#Fr12$f>5z}a52MWdvp z{h8H{$kP_=R8U}zUq-DH8AfJJx@t1ysF{`Lo!ha`&$z05DN2rjOz@oDZFaWam?i5t z!%rr}10f-tZ!uzIId)exeM>r@*JziQFO52=DH-S(xdp%AJXib%qe2&~+cx`Pvo$13 zcfN}#(I|c(B!;dq1G?lJaWz3Ffc553y2;+bZ&gK4T%rEc!uRt1!{{#c;!j>-Kdfi3 zp|J55m|qV5bUAO7EH89;W#mkzT{nbcb>z2OPM2{Wxz6Cu7S&NI8LLWP${rF@+!1P@ ze}Av(VEVx2%0$*R1{x{~f}rxj7idQIU{iV;+>jB@W%yO!pzXjuZ7wj7E~f5Un{u=_ z$t2K6a1n?s`~48lUAj zt*h_1;40!uZ7(r{ki*dCIh5=?*oF^Pq@@ z&u0w72O#Q^lTG`p9YmJ6JDsjx-7mI*cmtO;q4+S(H_xISO3DB?PX1g)x1wC3BegTh zKHvKJTWCLN)B7p{@!U~d7*rNjfP1g+PKQm6wlLai&nn-xTTwH=^_Hr%NHam+00SgA zWPi(pa2M|8bZ$`v&qMqA$m3h#pRD^I`!R0=#WABXG{>VPl9Y{@uC6vW_gR< z?X3fy>owos*)&WEKT@mPn|l`Xgne)OD5hv9_+G5py%?~Y^Se*(C=-%)R*pkUc$K&2 ztER)G&_n%q-bGlfKZlV7>+;B>lUv1(Ci-Z_04`!DoUwQ2G9@|r!CqB7Q&~0SBiggl z+e07PVdt1P;kC)51dLwLGHpg6S7p6Cq+!1gYbgGr@fiXsXa{do2CC7zuvAV7-(E>k z$on8OwTtItKK=I0CP+O7PC<8nT|G`W^0wumXflX zr^NS7^tm)cuL`f0RF$g|)2*WsAHnOklY6a0JH{cssooS<;nO3zbRyP^HMb$by>wpi zuVr>F<1;c$?q=78jXB4pn3)i~;hs3nkWwPv{8o5=)J zJFCQyI}zq}<4q&Xg1h#%>eO629|5j3jc}eEqxP&wwmf7b>r3H=!*~gvk9^m`qS?oT zdyZS94CIG$G&Qrfhi>Uy4RO*5WNv<*$LBT?W2zZEAn^)KC^&BEhctn=H=AlcTNpiRQZh)(uLYIj|4h?pFYQeO#-KLd-55M`6OZFj8 z?wtr%a_!zr`R)F6WTWs>b_=(xPh#@hJ}6&q9qVH654ALef5Z! z?P>Yw5%>IgDQ+c$Qh!(4(8tMILZPJLw>Z^V#DX(}t zYb}1^^s>sHELW%{FIHM6{&Wx%ktn>o&MrJiOHcPzHYUSOva3NfCn7QQqHd11mVtoJ zmJp}8cTbd*ca3nS&9OaQ@HGxz%fd%eBtzLPWeXT8zQP+|}E!my$;AOH+7LNt(Nef!N z$0+@#{Y0QEV(^|>-)h*A3(eto|JqFZ9Es8?lzh<2Yin zoj$=8H|Lj=sdG&^KZbsB6Gfp2g`7VcY1To^8#1wYBj;& zrQ-0Gj4NW*Jm$1lW_lTuXD};EgWp0na2Da$bn7E!gqk7Y2mN-Qo<0ltG+doQF@D+%OG@EGcP1>Zs$Wrxk8vEl54M?))- zE!G+nn_81iDTnU**t_d%MH$<`YpTwik%*SkTTpVTj5%#QPnledmsh^&E<>IdIHVYJ zNaT4@^%}?B+(_Hk(#_NbRe6nT+wJ|T$s^w3at$<1 zmK{azyHCr)=!_F6?j*)&xXEaJ2z(cM}*uUcF8$L(-%5{&0Z>b+f z4lRb@85z6k?V`J}vr{H%i?japFvuW~6q~F~6C~HLn$u(QeLm8$COV2ugsGb__lkXi zihDt`$(izp7YwooAYW1@2Q*WJ=;sqMjysSdL>_tupo89E!c(6j`X=B9ZSU)h+WX-t zhjLvd-i`2u_U*zHY?@uR$USnm3e|*z+qrS%S84^9UtGO^sP=~BWY9vdv`$&c?^4H1heR zs$9~9WWCNq#Vva+)StMTc-!4*L7vHL#2FIV(7r*&t|j-Q2MtxfqMlRSxPm;;a<*N1CadQ`eJlZz+=N8Km`X#Hc)NGC%HDTwl%!`?dk@MN+OuetY&mPaKL zDZLWaCSBYTEs$4S-+kN7yjg1m%)Mu{2#S~NtuN(EK^$9d3j1-G9Z)Ff5;!Fg;FU`3 zA81v1btQ&W2+mg-bwHg)?6^vfM5^2->+4?hqJtN7hyE-HOGB}U9T~rXTuYc9j&0`o zz{}aSXh~$XtgBY*!*vcOt|W5Xo#$pz9IG@~Nf(7DWWPqoUk071mtI#&G6lXz$M$`HtRZVa8FZ0k_J?l z^p`7@&#c9q(QHJ^ePSR`0kA^WuvUhA0TVeTFX(|S4AJ}6-9LQ`^4}j*tCtPTr9gn? z%mToFF%^BR2O?xMHJX31+J|&R5=LMyOrb&llc_`lEcGjgsmPw-^IvlS@!|U8xin4Q z_-}uAVsgLWfsiSKe!&C53jKn|FL)qu0MXR^f(HU~ep!$IBk-^=%&R~idVs1?@;rtY z63R}2Lb3W9gPRzP^B-zc(} zrr3BPi=}2Wfp{x-`K8;L{a_~|h}opNC-Qe4_w>;dB^1vbr}`tRH&qouF{rF@r$aK8 zHh$2%_@}cQUq%E!6C>Fb!1fLx4D;R4navR=)v?Y8859GAWt zv~K_7qZ5<+_joe|B;V=%<%Hv4yzNMm5xrtyl__`YFh&qj4^C3`8wlt;GJ8xG%QPnE zt)D+Aas^n+cGkbrjO-rOIxMUW`0-%5WB1LzBbh=l5Gq=}Q8YpF2unY@;+r?Y=kPU?)L)+G?h-;{Al3@Rx{)$Z67z;vNfxwY-C~!hxTM)#nm;u>C z7x89S>HYI`Fp}<`mCzrbII00S^W$#l`s1TNK=}K6v!9BL$^oDqXAp3jL1J6-F@Wk$ z&_k{ZKbO;QAc~6{KXA6S<5rBM87ippcn#8eS`OJHQ5jIYc+uMD{30$BBe7B>?*Rq4 zYh5XZ#MkCLGRFXHSN9J!k4m6*K(fzp_9;La z%i>esvrPZvlUa}m&N2J-aT4xJ|1Oh1zW&cArgDHR9U|S4m=!8ESCF#cF~^?yIG+2H zF@7J65Kt%LhtH9r0ykP#&-Zwexo<}74XN)-u!E*yibF4kra2@rvee6xGUcGl8idlQNO zZ9@mN(yLF*;&<8pnb_Ozf{1vIaY62B4P1En(>WyZ1Lmx<@QU%7I{%5$lUz`=1<}}N zHV+l$c&DF^fHcn>NI8+3gDwF0;PU^#_;cO@aHr)X%|rX5$O*xjRVwEXhj%B%dsceUUkTwU9b#owYD|@s3gT zrmNLFnVmR6Oiz(XZ;R;Fld?QN7i7<16j8NB96Oo5&olF3NTgBrTB#)qCG+)op{^6G!(#(L{fPaQ$HZ?#io(;QSL?U^r zd{+=n!f}N4)ut2=iMpyBpIHdBNt#3EVASq_#1fAOQ}(#m9Q9MeM3NYfRqP|d46=Sq zdQ>PuLTK#x6uN@A8cj%Vv&{hDE6(eL3>r`Bs8NFW(AedLo1ZsG&P-7tlNO+ge*?Kp zB6wL5O}0%STodXp0= zKcqn=KU^~_C20^6SE-f+@D0s?6ltG+kQbVU0NU#Ln6WaL>0cP8U|>A6q9G)`;Y6N@ zq@Y;EKXv_bnY${pJpk!(y}I83vx#ln$&74eRU`^kBKQ7*?FPt(XUjva~8y90IYf>KQjePpDm zNb8LpTca|KeJf_Hi3z@eIRTt@Z`uBoEliywY!=EZF6&k>}T512Oe%1vUVFpEX${9$yhms8Y{ zh$_1VP^j+F`;5q72IR=}KVoN?^XO9~Q>ZxJ+RezXPk~IB7zgQf?~jezig05U(zliY zq}5A0A<2~hT_->x_<=Lpq)jB+B$9MY8p_Qxr)^FWP-l#YJESGFGw?Aa#)_fJjyK!ex-&llu_OJe{fspTKYaF;ee6 zfD5R>i%cP88@g#BGYBGj{U|BvR3Ws~EFRwV$mZFm z3XJA8tiFK6Wl*)<1Z}>*pH8`Ds>tm7b2Ug>N73XLzE5z9vWxgU&^U*Zk)y-y+Ue!t zN}T=*rv;b#e2a_M2G|gAnO|Pl+9@v#&kGUSn=M`HE0`>6v2g(|JU#Ig(>!rP& z%HsG+=cNI=sn-(ATHxsVld$;>^c8bQ?E9Gvh>v>ZE35Y^u^*-PP5^sqcyCWnNT|DI zl@RT`_u2EY2op^s^jBQS3#oOfyjA0Uz2>lcvsd@%^?e+{jUYwmN#~Ok;?BMT)CAk= z#Rr6pM5GeM?ejP$UEE6F&P7O~f9;B={Y&SEl{e?(5#EHT&2{-`99P*E?EH7uBlxW@jv01DZ*`I=N%p2MY^}QDGC+ zw@54ku$w4fgqli+$R}$1;$VptfAM&(6Jq3l`M4T}tEh@VYujZ|Jim(&m$ALZf$ucl zaP(_+1Sy^t)kA!LQLu}a$5Jtbf9=~%m&n{81ltZ+Fo0vdqqB%_$*&6`=TE3LYKr6* zFV(!ZtnpX*60W3wVt|j<#;V=5302KmohXFy*!-U8*2kTfS48AwqzE--4PO|}C}H8+ zkxOOq9pispJ%b3b-=~zsfZOP|9UMeiIiH!etWn*FhA^61;1dGesL$4j9@6s4s@MzB0>yAiDCP0Tr zeAE)l{xwonExWHvk!(fR5zK+!v$}y7y*h{m0Z;^Rvf2UaHK8j6ca(8m_%1Ubitww` zqK+0HlP|Fv+`8F$+m;o9(YG1LP{GE6n}|7E5P_@JC7NAZyAg3@PAK0LL|L;ppCzxh zkyMXx?hzCVSf!E2YOFrh&zX8fHFD{UD6D~eDl>n=Wzs9 z{0zaGOwLLv2%tTU_IxJL4!V>NQ@xI6&rbcxGGpDV0n^Ni_I%dYbnSxW(Y_3n1jx9qz+mu!~T!E`;9nihw(g!*5ocQBB#_Ks*f|E!)t(*vRI6W{Bnz#V6_8Qe+Krmwn0Q>6^f6;-+*kAnk#SdgU z`3oPv@bL>D2!Sk8kMm1ke(B3Eefgy?NR0%NK=+sZ_+>wS*^mFb?Z?j1;xKwN;n%H| z@%z~L%1@<~P{pVpJVgD3dJE-ywSfvDA)Z)nvQA+JiNZ^gPcM0?l2P-X>d|7}@_%xJ z7fL29=X3ORxIZ&tE@l~1u+}}nt1o9hciEj^3Gy)g;yF}Yk>CE|KcM`JxKBv7Zp!Tc z^D%yZwLef42lKS4q5b~BFRFgZfnU7(`=I^BtN(^qNw#DQ`B*3zR8M|8{m(mm5_W^< zmV+hC^08P%4Aj74VT8o{9a7${={4}D{-GYPArIPm>rhEHVm zAPIu$Z&4W>LHI(&O7$s!TcX^z1l>H(sBR5q+25pdOt-x7}zO6(HP!9`R@l`4WkHfKHgqU z-#qinvv@1O1l(;bf%F+A5_y3k6UU=K%%T--V&ikTYl4Ei?!SWmZxvo?uH!vB*xC1q zID($GU(v@r0n*c|_;=;@jSE0z;gWx+&i{Nc16-=#*e_5M|IhV=Y7iAeq`Bm#oCn_D zoybq0^9OcSfk|Geq5hZfJ^zO!4RG$vA?GQXNUL?4OACEpV^td+&C-T-vRmf zc^$C($7r_g9f#e7=OL%2L_FyZY>X!YE5^&JRa&*56XFT zZ0a4hoY05vOJtBsb4~(%q<@V!&;}vEZ^6)u9w%JDziG3>%GNApX}zO<`Ir$@;b?%* zxXD_xV?fId@{<4f8|0GY1rvv#bN4?}yGjpOcX0|X=WAy;DuV*-bBK_Q<%$XXcK?{o z80B>zjhY^>&$L{(p=nu{eqwT=mihzwnIE2!JJlkJ@0Ssg0lzbx?Z5fI*m~=zDA=xh zm=I7&3j`!oLL?=nL6MMD5b2N@x5xv9k{G&6>6VtEzB9h@{a$_k zx!1avYq8*(>pExeefB={Ys?vKre{)SH-1wwmg5|0f_ANU#n$?#N7-po0eBCqpE?SI zJ7Z?yLwKx+>ymtH6tksa)0uB&Uv*1uA}xC}T7NdaF+0NSq!znaI@rEC&pZ=xm#lTm zc`AUYsqEaqclm%Cuf6Zp9$Th!?Z_hY4w~Ttb2hcA=cBotE01J zlfKStLD`S;?^(u<4IDR|VsoMYPQMy{n9gmSRi|$l0zc@}^&23h?D1%GxF*wuctikqO(ExwHzT=FmypekVs|y>_;$FV0>{yZ`oP zr)nsT08{@wqEeEti`;bcQADNBWVz!9B_!`XsEi9@q60&vry$P1AsP_Wa zb5(wr&&^ft>u#7#VduHqqk~e0bVl?lYx%bQ@TztSIW5c>idf>twVh4cF zZS;GLFw;C@_WN6v$+SJoqb!4(%3f|u$%Iz6c#Lsf*oC7A!>GP!n*bjDb>k|suVa}k z2Vw}XYZ#w~o^(YlaOs@4IeJ_t+KV27AL3j~+_f%GBWSa5h{`h`EAH88DKPx!mKmLA z#OY4EL;p82JDrB|CyYG=d&~YVTrK@h|7iQ#eOZZvm16JN_bK@Kav_(EIrz-1e#!M_ zC@bHqoms@0giHNpc(Tx>S>%99u~Fpo@AB!e=r>%I@*}0Tv_n%HTDy(5$bvF-?&?OY zrP?rji0tb%dx?kJy{elB>ve75@p1Ra$zx!(@m}9l81~i;(t>a zfA950$7dEQqO1HBo8Q^*1zPd{Vs@_tyV7i!=voWvZQrCTPui~dh>#^i`q3kf#djuQ zk$j!EzwQek|DKNa%xB;;4_Vlc6}`lvr8e@}dcKl@hUchHUA6W1#xzPocn^~?DG+gQ zG-EGE1trq@BZ$FHhhwc;$=f`cA^5-{$g1&ao^*}*XTPWWvvDXKJMp-&hux|7W?Qa@ zyCl+KwimC{bfM&X=HG@(9%~tR%w9tBTRv3c>z)&<0*ERr;=aZaMDJ;Ft!nC^(%3h) zUwZ9>TzIVU*O#pyAAET7UfcliYn7XA#Y$1zOq3#*Bc6_r9C`8=rIjFjqu`4Bn3Q)} zcWR8pjv~&;1R{6j0t5IjOYAvU`VRiwBNqDNR1>rz(*8~2QJq(acSUI8ymUfU#Dm6DXcEfXY+GU>b6X1~_O&N5V2?^gASS(FkHTc-@f@Xmlxm|nm?Sg7B zVJ~YGzkj9Q$#E?~t9cI}{*lx*gv#%M9VgPIimegr{8nh2Ywb^{!exJac`^Him^-G{ z=RidImBCV1{a3imT@{lZiQ=PepFCpw5pmXe6|#1fiy1NKkoT?VA`dlEzXhqis(=q|L0sCf~j%dc2dzAP6~@x#aw+cI+ze zzQ>yE0Ns9>FM0YTz6^`ct2W?gbja2Hb*0LIo!K)Oq`IM7%48(d`)yIa9oun{{gcLe z@iy=6-(0;bckEmOe3BGYhxhhP^|qO{{}0mvF2)_u7tXtp&D5_dWj#W;^=nt`+`IgW@aS5+ zLj#XSCMD3+#eX;W+;u$5c(NYo8KN^Ky()@NamokjNW2cJJ)deH2N2Oan2+>H%S$t( z9Hjzd4W9lWXwC3XOcslNv<~$tVc7%V=PjaVPshvZPL6etVVBw~vN=V!9;4559r2m` zI6dov)(-RHdt=jj_9jgs8&#f}wUWob?{ZZxTw5HkDq9(=;ES;RrnC6{Ejw!J>&A3* zbLD-dHrvrs<+ZvKUqo!DR$%gzPq&q+QW@lX@uP981bi?qtiig06-W0m3CYE5OE%de zZ{F(mQ0KV}$kZhn59I33x94OwIJMq4_KWQ|=_@i*teB0}HHD5@Tl8ss+}+;NPIPGD|AqJhOwUsIzf-a; z{THWdUr)w&%&(ZE>BcqdCUiOoz=C<YOqL4Jdh8q<&trUNs=<@yMkb%X`0kKNBED=*sT}Rj zclP|-4Sv4uuNTj3olp+o3=7(h1d|Pr+?+in#Qb5MQvlpX_nPE z03AQQ{7rHW5^Rqga=5x-FxnEcho-mj?-3$P#b)!(egoQIigYZyE}4Yw2^L89_J?}K$~@fn1HafAQAo3WWHazfiCn2K*Ye|sbYcC1qIBk;TgeZhtOm&VeUOe&*O#oao47*^UU{UbQ3l&o({Pp{E(|= z`yr$}vX=*lskm13CIPBN0sa?}e9QirvSV;K6i~tRsi33Z)8V|9Jxtx&vo;F&@^b}i!PyN*{nns6HG4@uyTb$8=xs5v!(U(1%{$(A)+nBE8rD|# zy5}0yjGTGaJxo%P+ucKD=>4PphmjVwtpz2@7{^nGu*2b9a+KfHhqt@YY zpaS)0V*BRf;DZ_Vum>z3`aH(sP17U#UBqz0)K@J8Q?^!NYOmy?WAEfn zrhMeJ)aLu>B%o5S5=Ef{W_EjRFLZaVe5R2~Z2wb4O8=*bob=Ti4HXzG5S$*}q0d<6 zS@E%YsJhKRqV*}y)#oKvK~+kk-Z=;a)M4;m$bQY6YhXSO!1nD)`-MF-%!h^$49TZe zN5yan%*Wyhbye}V^ZSFgbY;A2+TL-CZoXAR9zeskGqLHCdW4TWt zu@2&Kwxulqd87qTsBVuD2dVrkC6T9?{>O%)?WM=L4csCQ%!z6CrzO{`w`7RytC;B$ zj*?Cen~|+cKolKy``CJg5GPo+0QCPmTokTKI`}!K=_o#~{t|uh*@xza$@>RNMD=@% zz0Fjzq*uh&%BN)}XKWRr)5nh5*Nr0!n@W{Tk3*>W5}bd+6Lo%uzSib^l8e7DdHCz@ z1`SVi6`Ajzq0wSbdS}HC!N6cbcW{q)?7x~#O!3MJz!%|*By$L*+}@duGZbl{KQ2>| zK|dgUJ%FR2E3(t4jzUU@i|=ZUUfbVvRZA*WCL*pRuvoUy*&B0Kt=+lM+JzeefvGSH ziez1@!93KM$2_WY!&R5d9Z7gua=Ys;mfQsE)|*3vK-D&(mxlpG9(>%(Y)SL!P!<}$ z=sZ=GNFFY>Dc{HFL#KwRJ2^rl$Az5YyN^`{5rmik3%M|-GHbQK--%Cuz7mu7L@f{# zZc97{!Y*c(A-PR0lf=BU&!Xcn0wDNCoSxGw_0U~)%oh@MwW^6X{K5M8IUksP!usZ2 zQD!V7&NYN~%OsPKWC_*G&D<=IXq#iSU`XW_eC6g1?~Dzw`RCra#jVUl#qKDwpA0-k z{?$7Jx=q@CJJjlk#g)N)!`e@uG_rnHi!mBXCr}R$ksoXzpZnhEyukHF(|I{)`%3lH zGvb+Iw8R!uD&PD0+U63Q;hudZ8f#|bs)?^IbT2=v*37-PPC!42oVUo<%zfgyuQcPi zahN+e3Xpv(_Y_p5L zma;UoY+*HjdoJ0Uww=gH%!dA)B;_GxU43obbOP}AMaPZ7`uBekWCMcK6Z@z^8g2T& z`=~$2ek7*_W%{iOUXopWtt8C%RA!E7qm1mk0BL2%aBL^p2iz+n*8ZJZl&U>K&#ZYm z02W&5>mw4nsk>ZQE=a0tjZ)eEddu+AE>K{Hb2Yza_q|2I!)k@VLd@R!`YG0O0?x$> zA&ai$r$S#naZg)xZO$k8+2vG#tg$&{60QK~e!3Q6QU8j}$R!>rAE=IH6~kAn6F@%m%`U% z^=lpE>cwH3wpdOThZ3@;Qsx@T<#u;Wwdbg9Su3OjQHTfJ0YM&`hjo(Mjb}s@bJ;%L zLhNVfRo=%^%>+rGeW4t5k??JZbH&~=X_`4it{b8*Z56d~Ifwb>aPtS}$l#w7i0}qL z=wa~sVXlq<>um)?5(?xY87Q^BL-}#3UF7!?k%x3_M%Y`}w@$fzg_jw%qhoj@b$Pi) z%FkuM{z#}h&g}WBCwZ^yu>La#I(@_`yqge2xAVnjaE+nSlwt9vwO5N6_5p~OP_A7eZ9DmZvnxq9(M6)^ax*Uu58iPxh zw2J?0!mqHF~!CNa;<_uzh2DNw}F%>LidEl}ehS^!x&F<3Xc}dP-$B9nE!vrXa=q#4y zW7^{`cz=|p_guC3WhB-RG$P_d-!-hiR(oJ)Z6eUJ0!ZZ|>1J3$fE<2-E8&pLvm7*N z!w5=FpEH4+i%MR0d}AX6#j`<2+Pgrz97rYlhM7K3_ESChM(h2t6qAs}M{xqZ*~47V zu)rvQkG-&CGXKzKCFrQ&1Hi!fDemW*k&-Uvgr=&ajT z^m{oUW%enjs@lDoO#fHm{qO1nDl`zLogc>Y{}LwkQ*LWxY~r5V6i0(G{QjnWA0IqS zxkI4tsON#530eG#BBVk_k*T=HZ?u<$adNM$7JnYi_eSOR2(th1J2@~EUd|~#9Est# zpBOvCWDT$-QAEHKQb~PdK&jR&@!DSs`HzB^IfJxg}ygZ8QSEor3BFz z#Wt!Z7oQz6f8H-0NF$b6ocjB%i|*=ZnUH2CA0y^mO9no#t857ZR>`TIm!qRyWWP+*c+DRoyXsgIpQc(w zq;T4fSN0>#(3)$^k}GiTN{(%uWm*dSQ)+=DphH{gH(3XCMx^bP0!etPFR(UST*DAX zYd86N`|hiS!ZoOG*J<7*M!Uo=fgU$>hH22tzjI{G{p|D(eJmD6eVUwiV(E{2ZLM!; zG`0JX#OGCPt!cyVc;;h6QuD*ZE%d#*>wL_l*RJrI24}ShgnPxZtM=WgZ40^o?QaTj zIt?<{!IqAfhyI<9wYcD-=&VLcESt<1+Jk7Mo_KvNbxgm^=Qy2Y;ijz`*sP*lAVy0grBs4pvNESp|= zLE*?_1CPdc>&#n)&GgaQ#pcwHyORyaWh1ZM3z^+8xANjYoi80o*mP4fPmc{iN^oKO zy3)8xv3ZAgt|tB`m*i|t%U=uaNIu&}>S$HTh+9E7bKNxpPnHI9mzO1W*C!1>Y35qx zID58|DNfXI{Of+yCPE1e!@nh_n;lH<=O$l=13`f5;+`IJ7xjzr1Xi2#2&}> zH5=hyw{d-}NL*2hOb5 zod{mqkIj_61DVnn_B*qA@`wOe+HZNqT;`ss3@zK&-(ijhmt_$C8={L+hA0IAotwI) zIbtB1cT>K}J_N~z0=3Z}V~$Sbq&bb0$Fl8CHDQ+pHTLccs$y3KtOrVBS#{Jts$Is-^19^lSE~+OIZs!3dEzjx1!xDe$w!`mz zj6bn7MhjKC`gzcRFLwX!FevYJpLhqHXWu@i*%H`-K2;iPb_e%JisefB_N3fs8_VI) z7GP{$mxwamo?&u!jgM2M@?&O|t1vlft~~zs>O!tr;l=0BB%@U&B7rHyS(z!)An6YeE|L}aJO67i^ToohI&T(F=o5!Yz!#nogdKc@nE4`>a&8HYyF?d`0AqQ@Nzqb% zN5&j<;BLLi6TJ$jbOD?ar`hkd_T254!WbkC0CTWX>AOUh*pB|0D0fedN}e3@eTdHu zpc08t$B)Gd1@)t;e6fI-TgvI^Eav5Ul2>4ycR9Of_~J9A%tX&mpFK%;LxhIcUNm2E z4zVy&;e7~lvJBBQvPX2i+Jvs!QfETZApQQ^G?>g9Xl_*JboyJ+8ojsp6*-UE@F2k8 zo=ANiA(y$(Gyft%YR96jNj4CdeV!!LJTDD6! z)TGvG_6*b?FnWOM=V4TQagl5^%tUbe>nnO5Oo|M_iMFKf!PQG$RK`Pz{m17v=gW8u z){QI3Y@1m)d_GAc9Lxu!a;$y!*2bTBz03k(r4OMl8N;rba|2jvcwerryWdob@1&`h zakOoG>GHMcqz7M|&_#57n|~2|m}N_VBV*dxg!7+@Cslv$_iEYXOx1cVpOv1^PJbd% z?q&AZx6~20yBW`^d+zi=BaQ?|fms`+H7Atk;@Ahj!u>nf20mLq7T#*- zGpqN#Iy4CTWH2 zt~~Y22ozHV7&|So>``IVfM=r3V%kH_9H3vh3Jhhul9;AtpxVRP zx`Kg#Q@8Y@Ps&R+)>p(L~Uj(EfoM^DOKqO1oWWR+BWInwxWg=|Oq!ar^H zN3I?@NKk}RZ{MYFAbErEl1vL&qSA8+kZwB3DS(fvob2mFCdN+?VG!C{Js=lK!(6&f zy4@(+dS9fiymsYUV*TB5xi#%pk%u<_&hvY)77~}+XIJq50UM?{^)NUy8ruUwujSHX zJ!1~yo5jE;duqJgO?E`^Q#NJWHbA`U3OL9}!~ZScO#rM*VIRn1E-|<~ z29dZL2&SuzEDqp0qM;fqMugjpm3M2+G54{_ zwJS?^pY$2jj8XILlKvnp>OSV!JbUODuD{svB>(Dqc~?S!dfvU#{yK6HQ(*ixZ zWk)W%zBKe{pq9i}Qp9eOXbG zqW6cp{*BRt08_od%204nUJef=RA#y^V9`}yj+Dvs8$A-`Ko^w`U)|AK%86=KfuBfh z8eKa5j`M#`?f3-eo0ip?<@rb$c{TTV8^)gb({8)mY`*klVw~_0PnqEw+M;=EM@k|= zxMVb8vK_paA^G9?kHojPSkH<6U7e)}!BX~4n&^%G-_u{5fbH``HC|;ygzZ=S4ma3_ zhfTtSWw&jAWblJltX?XBWh;VW{?7o~(pUq>wGZ7lJ-(jIx7bMYl*f6l-A{8y=rL{? z&Nra`m3g#^KVbdM6K#&gHk*->)-6Hz)e&a(OcjB=xYtu;e3Q z5ggsG^4OYdI!xw!L&d#zz_Ji@bTysf^<<>4Jd<{@anO+`dS;o27Ss28{rnGYKJ7sa zVDK4W4m@Admku0JC@h)*!%j7wJ-?) zOpR}%En+^B_ohJXgXyL?I~|(ppYsUdISqcymmlP5|E2!Sc%_WtFg1I1oOW~AoW2)n z0??Xn%MNPxt-HSqu#C8chOA3x^A#ulHt$RagIovPVkC)w|7YXo(eQEh$RTT zgErH{D2>vKFS#2yJ5(e({~4t&VBzR@sDov;gr3Y)$^(!)cpNF?Ias%>+Nd z8YEM+NdCD+6u}n@2+E&QCtudiWbmu9^#k+SE&FRLt8b{k1QEc<|HjGDx?ipU%y@J> z_trRJ{1vq~;f3y1dOL9c?nAZDR*%@A7AWNkFMa&*L?`UUGpZYp^Ad*4i(oinkStp8 zbmPfztxuIBtzAH5)1_Urk+m1Fu`0s$ZT;3b9))eOBw?+(gsl`pn#3fPA^7BD5BWFy zc~@nWE6dx~_2o&o6E`nS71@LF()C$w{Uin;WVEHOJpz+%R~Cb6=x$-I+8b2FWiWit zJ-4!MTsOhDFH%Amudyd&H@er8N$6#+?v>`hN-bL_*OF#h#r0(~B&u~UM@b}n=<(mD z#H#>@1OnO$`#%sM?QK1j){ui~L7ea9&(KJ_QNuDj{dQeq;4&|NSvGL#SIXCT?&w(` zY@sF7b<6CWpvm6{rFk|z&LGOr??@mU|GdEEpMbqxUEA0+_#R?Tg8=oloX^l_=TY)J z45L1Tj`p|rs)%;zn6np*6OTW`v31>lyY7|*_LCaJ_!Rmif(4?V_%N+Shkll^*|RV6 zvfv#X!ye1HTiu{_)9!hH(-QQ$?NHeDqXKtJ%++=CO2Q*GZ@Z10x1@t!83sY6Ces6% zjm~6G^_y)E3@{q`qh|ZxPT4d!tv+QbZ8RDv)u%G{lx^+v(&Uj#(D}EEC*+@>EC1W1 z06b8ELVggJ&(5Btj8sF=5nfe>AQ3VX|FPu33LjQA!@3gl;&n=GklxTOI>Wg>HkZIS z^&w0qjwIz6d`gD!s`c2>^4H^&^Ym#?S zVLNB98+QVa@v{V%9tFGXjI{PP9|>mBca5}gswY#uaP#6itVh-+v*^dx28%0KeHk*Q z?uz@_Hb?pn9rPv}unWp&LCS#}i4?m?y3zn9+t?KALs68ww<2DS8j(7lnCAF|q zn#;=2gACOq`Yp;Gtq@wF&i&S!hgicU%r$#*wE5=e!7vqb?cVDd1E!00k3VTBU`N~t zS$WjB3gX2Wyh5PZwkI}`;LRYtr|T^A1jr1p+ltmcv188*zwOTJY9iZzY9p_PjCaU4? zOEUELWvo-+5FQ>O|Mui7uN&?kJhfDcc=n8dc;o?Z!8h#lofdt!eRrdxo4j#;&NbJj zVuEi2`*;hsvkcIWVpWwGInX>nq-@YPkWbO~S4hlk|9-L*1pt{WqOUUihq`tx;*~Ug zskB5!#!KvtWWByV6-XWnR?9msl%YZsPw#@A_`;s~<2=2^zVsc-o`6D{?R4xoKCEiO zMehN=b$;Ek=R7Oj9vbb_gQPd~S``4Bl&Va)V+M;j12{(XU<&EJDewchS~QeIRq$6I zdYf&STc|C8z;2^oqnac!z+3iAsfy|oK3fg;`HD&m_mO{N{iYL%hjcp`INICHv7_Cl zUyNAvLyS1KKlgp-d!sh{nePn=A#Q84-kn6tLE{WMTJiO2IFnBGSYM8(idZ4}`xPmi2SlXMWkdMSZTP|rYFSE%jT%@4O-9z?S+P84;?T%cp+mUc zCjHVgJZv?Pv)HETzBabl&XtM#7ywcUx9dZN0=%z5&YDg%ms3wl)>mNPxNh}9fm)@7ny(ymlPC2LJJyLcTO z5faVM#LvV@WlYqW_-GD(m^B+#60byhUg2wv_q`Dvrdhrx5TC#2Qb)$OGh}*K68Xyg zMZuLgr5dio+lZc{#92AotiSC^xTo6j3PW%6ww#e)LT?2>>52rw_Dp!)@%GiSGTrAF36Y|?B#@#1pnPD0lfC$;6TsfHp@ z1-hjgT1q^&%vo>$f)WzzwN@d=1!n$k!KNrs+nK=L?hGch*g>3TkeI4CpKv-?QNgA$ z&Eu`46RknzN7C5p_}Q{F-XG^k3h!yoYkz4VgSJ{?wnb0i80omwGn7VF+|@*XVr$FM zqfCa)!RV1{VuzYHTPP_JU+yV4n`(x|ncN@gs>%}vh0Nkfuc0%5)w#`rq-^<`ZsLQd zZY>My?@)4oA! z-ssgnCF-)k2;^~td1?w~;P-WKQVHd(BGNlsDok6?n~277>E8qu+{JuFY%wi5Ve0$! zu-kXGGr^oxWZBEY8lj2L3doin=-)Y%z(d3X7e$zUMOf$84A@3~G%NM`aW&}5HAu{b zRZ0*uPCfGXtx+rAaS|mG;JMW4y0x{`67#t~EB0)m z5b?tN9NQc(1e=LaIde;zw_o;HtC*hwOGrDaW1a1yR+U@sHW*uIytR_0a9M9}4Cvaw zg*i0;3$ORTGhA^d0D>^Bj(m=W$(j~>ACB(P#&vyI9fx%bFzdZtHMq?A`_>OqbzHm? zRw>E)EPp9)y#E#!yQS*5?{I* z^GncX?+z&=^VNv^xR?NqrQ%g33b-ghU<@*psHcs)q)xDt%QoIyz1VxA?#I6v^w5vw zODU1w`b4-zQQm(+r6v7Df%E6&2e(?S<84-_s2jPEIbN|okc>HB!Cz^AU@!{1;g{ASoH|8 z0O;)BBd*>Jq3uJ0EBx18uyWNDibeBfgVUyD6%awh>r3pIiI@v2x22oH=-A=U*~_&9 z4+r8Z{MB4|+xUx4Ohe%==l|=^Ufb16S`x%~(Eq{PN#9=$-jj?ezbX*yP{Zm*dCCJz z*ghT5*43WeS4#X`b9N}v_@R%4hi%YmqrUdZb(7DFWbsWs#-xzkmba{aqqly1C4uqJB%`p! zez=G^wo!|PFyJehXnT2#u2h!ei;D;l4(D-BfyWks{`l`|foqUr3%U5i{@)_YC>sC+ zpUZJ$g(g89IwdVyu_5h%wR+2`>AF&i!yDg2#nqzYb*#YV8^H2uH@4FeFVqG$^AIyr z7S$;*Z57z9V3zaCV$GZyz*Rf*g32HQYv6OqxPv0N#k=TX9Z!07uLy$4PUZ(!00y-u z2&+pRZL3=j7n!Zy^FA1NYtV&s@NI&!XWU9AiG&di^e9XX z)<=^Wc2WG6mtf!5N11o!wE~XkwXZq^rXMBnXDB@?}G1{ z1;AP)tzEhF>x*nrnH+Q{AbHg7&&9ip>F=HiID$hsVEfIiNMN(KULcljT~N!mh)%NYVNSjCAdmsbH@9i5I5`=DDAQ7a0zwtPVX-SM7)_t7%b?ynC|I%pm-R z0AoLd(Cwp6SRTx`=U3`iyci;S;cmvCI!q|mcFlLZn4>@XuUr*8r!$}^zQdu<{tt== zoG#zB1&-%@=IuPO1jv23{j}MVQ~vl^n|Kp%S^J-`p79M)5XC||v_Sc|mDE0S`ay#q z0tZKQSwTix!vZIj^Aa>z%^7QK1jZ*9tvL+FT6PgW>$Nn#`(W5(#SX7$J5`n^v2yM7 zaVSh*LAbQ#AQmZhU!QO~+8JzX3Z)I4xVVET08C8CAm%ns6;L4ACTKm-z5oMSK@W%t zhL}m4LrRk|Vrel5-&py6`u1G~W3qnP@`)qK1Yi9i<8^iCnYOp=aa-24eypmA3EbSl zA#h!Y9@y&idwe;=dU+4=<@uRS-~_a7J-4F0Vo&56^Wnc75H86v(;PIOv&>2rhbM1@an#dP@x+ zB7WNu4bVT9)7J56+ex76wBJ@)F>}C!NXaTZ&=GISVW6-^+(s!3WNS2MD$Ow|$uGV> zmK>})Y3|GaW19%8w%rxr9Hf339T~OZAx^#OFxqsFO5c=YVE$D@AQ8;5zK<1|y%7Tk za?z8&y(oQEcMLqoMmIzM6N3rMG>aI2X*_c((nwk!c=k~ z*?C=x1sJnM7g0f`lf+vT-r6m@4=~+tI&|smGjYQ*SN8{q#85NY2!aM_(Sfn3TvvOTRDLEDRs8^#vbYc=nTz?C)@WARvUS89FyoN^Mn2l? z!c-=N8yMcp?~&fD1>+(-I#cXw_9p5K7uj$5wV0d1$`3Qip{_|VJ|w_%#1{8mFy0n_ z-(DRRvc&%#n3x;vJLLJ!sPN{^vE#g40>Ju1E@UDT)e3v51HHB)BVZZiPW(A zrLa9;$)`cNG>tv4{L4^ZfA-@xNNAaGY>94x&*d$)(PwFvfo1D)_b_!$wP~5?$G{L_C)*%d%x>c2z?)*i2aRPEKb`4j9&CJ@SitNfJX4l5#9v2b zVx#DRv5FbsPZiO?-|n!ypa;xWi?9m*c$ls3_fO&qNDBuJ=_QI_$?64GmGN=t7UlGDmFCWpxBAP%|EwqR5dOVo9^rv81300Ai*y`lphQRXFF)zM{m|k^yO9k*y!X^9j)lEKD%HyxgMJ0MpSl zkZqfinNa&va20rRW({LGTXMjM;Ix#Li7C5k^;&TX3)}O`tOvQU1+cI^yq(J6skCf3 z{i@uf2g@HJ>+gt!Z+g+>IF}17Qjgv7}8+W^E0hlFA%iy z$W(?+yC#~&s}vc_;vs{^?+*GB*rgEl#py|4*Ei ztA9I!{iQwAN8dLq+(0SZ1PFqFI!AW1Hi%0;91RR}YANg+96nrWluc$3K=# z1E>iS+?0h}*?j6;+C|?g%L$1g%%TkF5TVa9@eF+54_tPY)qAGyj?&I4w?$Kh)FTmz zmbt$tyw=}x*B$og(Ax(jUVu81qX8!R=?oXb2x9JPHLUA~TF5S8;d2z50zQuGq_QQz zMyCO7#H&ucS$7P4fevcwF6!LZ7;G-zav%qZb({m!NTWznzD{u!%f5Uvus*@-I!EXv zoji1qFRM@MYu`@o!Y3~#AJHyn1a$aN^Nwlxo- zHgO>(d@4VWSqIbx%LkGNCT^gYeVI-6AvWH>A!ut2p{!2lCa7#mjgIrVpcCw`*u~dW z0I^P;Y7P9gCbTXNZPbW_ ztECN`O>gyh!QaC~`H4Bp!3}p0TTJSX|_((z9_X{C}(oCp^4QRN$nz_=!P;+ZZX!zwzLVcNPGNAOb z!tcVTnc3XEJSA^(&lJNYB{?O^cIjF~gw(qvGuJs3Ko-qy0;Y^KZmtUWmeN^3kyNYM z=aog>*J7}i;WJ@|Uig z0)7jMqr5wkh|wtDL@a)_X&mrg)7E_dkpPO>f8b2=#Gp>}{^TjR@b_oWI29z-@GR@- z)7?NRmx$N4lBKZi1RIrFXh#45d6nHNGPH-~-Efg0idlN!<<~lPOu`<e5MLYCQxIV)U?<64pBXw&vy4_*In{e`?SP$_+!fjjqBZq1XCb6^M$>%f$O!y)AeC3FBI_8~MJ_7+K>I{VUcO(Sdhh?oC@} zFJ-W7BbdCph6hvd&kOZ~Eou0|FgzP;v6-F}3ATc{0-!KHS1EgNfTofy^$R_<=6Qlu zF`<{0Y1WxElg)RGLGC;fK0Yl<4lN^nO=2M4%<#)rKv!;bt$0fObx4A4%j{@XWndZry42&jSyS?(!9A&bI#JFr_p04u*PEnir9Pvf5+kOdaT60ZCP z!AZZ98f@r)!0d*6t<)Pz&KYp~IF37D6$0pX6sVeq2%_Lv{)k6tA{40_LNQbc+%VA~ zmNr$KgfK`-6b-eOp}fF8KAMvl`F1hKN5pFqy887uQOA3Z&2+3#WB^K3eiMk7o*`Rq zSEcbj$GK>9pOmFPAWe?mBW?Moh{K~m$|S675H6yAT)(wWcu*(J;^R-+#Tg4zoh@k% z9W-Oe5bWGayTsx_t@1c*QOGNvs++#Yz;PT#Mc^dt0EleV|y!6=l&{h)gX<`ma~ zw!307VI_cQ(M@o4g={^0TQ=dT2&gYEUr#dbw?MJ8%IyE_!iaI6`;{Lr_37a1Xzhzx zPx5Kk%X1Qs&~istvXkbwUp%qPg5^PfcAXrEllGJm#8^+&cGhRkZ$nv4S{J=`6e0v- z)V!IPv_y3J^yj?d`%8P~kj+o0{d^HCLQ8;)AR$@ia~bndn3Cbj7JQG}cd$%+@Kw!5 z%K{yV2Dj13rx3OzFUm=?CYRI#wJ&%FwFt>{m&Kmx^LfE%{{m%CD1kMY-=viJzmQk{ zGZv~z;4Rkq_z>@cY!clSMh{;x*J+ge{N`|(Q%<0=Q>i+%^lsbb5PgwRB$-=xeNjS# zLSe14K0@}2gm<|;eu?ug_vU25XI1<&L8P>l!|fmaBq5UT)FCh^qpw1BS;Xbzg`5(f zgWY$r+z(Qp^EsmusbEjd$E^?ZjlkMaj2d)@xsoD*PdEZXX(BRkZPK>ScOg&s9XtUy zKX6wH#1qh}fESQjOuTe-2T|M0cvs&K(&N}Uh<2q*)@Lq#1XylU?dPy*H+{q=-=kD~ z9&+Is3iU_Ja8fvSf4@ zc?GI5CJTFm%05uU{x@jU5N8#5$~i*vwF0ewRGpQ?w_RMhn>7-*6yk+3@sw6ubG5Hp z6|8{TjY7X0ru6?vhPn1ak*ngHN0yEq)#y6Sf(LxB+RBM{?gR#@WB5HSso2%6Mk~6O zLnHVnlW`RxghR^HswF$l0qJVi(*znl)vXSyZ_x?9dWtW1-2Kw zscjV1=?S>iZ7vw1DF;9^fl;K6u&vyd+h+A(uUL^o;BWDFX%fj; z_0%_mkN$gUx&1zkjHvOKQ&`?Sn-Oof$$z@54At@|Qe@di6&3+Fn$vVVn%N&r7$*9x z55OX%vbJ+7UGfzZT?+=bmnguD;6fJ5@}P1fcC*evk{eE1q|{KNnRD-wO)JF7!!j9V z=#m-GZ}40wuVI6LFVrKy43HBYVFSsR5NxB{ii{jURP(>`>}7c#8-Qh9WoioIYIMDe zIY~lTP48W7M~xcf=3zO=n8)yLmBeGnaVM%{kU}~mRqpwGis`jC*do&=(MpMO^y$tmfz@J_OH zK6U_sH-2<1)PI5R)RYF*Al5;@`)1cLE;M87XXGEG8?_Q&V?*bc$;sYajW`v+Bi<(K zG<)jmgy(W@Em;P-hR$cP6}GxOr1XAQ%x%0B6#XatJ>ULgQt9I9wjKJWWuA7QPX_EX zS8h8@)z@>;=AP6ZtP$rND}4gI_+=AvQr}_o5r^6Lm^0pk2N2lcuKsn05v@2@{8Q8$ z)58p*I&|3QziJ}IX&vytCBMk}*Gv<2vnx+u)B`e4(dWl5DSi!uB>n#6$?-f%0vmX+ zIkKPXWI#c~R-g)i=;>gsO&r)U`B6}k4>nvZd>4iacx2?5dw23rpAfmc+LkRc~r@|rR$LZO$}7+ymvgpeVbkD-3+tM1$Ppy9dC@BUYR^lOYDNL)QKa|QoxjV%9GN7I?em18+&HJHHH+6l49ULgB!J-8}|g< zu@7wAP|zhz;6&}yjyWO}@;j^zRh=qr<9>*>$r3%ps$MjdUTFQMJm4I_2vJ-CN_u$n z*8VW?j+UGG$8Pugx1eqdXl7f~>Q&!q<^4x98$CRw+dvTue12m<<&r41#f^TcjS&SAc)m#d8&kKKZJ1 zEl0ghis)mmO~dBdz45!4zpkSs~^aCW^llt;+|Q#Qik zJmxuoVw;+>%(-6_xo2Z(+K|k>nxFx~VssyX!D67+B(mE%g9_MdowU#?lj8(B**M_o zN=`2jt18tvQymIG+UI&fl4~!>LVm7t*b;gGMz9KU8rjrED`cixnQ7FsvRX9drw-|y zOsL^7&au0RWjxe5hrIMzz;_m`?`p`Y=#FLqK?*Sa16+7rFQuo7S|{}Jj6php7>9#X zO&jV;2nUWYreOulUD|DQ_06p<;Wmu1*ds8;-K;}`cW*bw5M=v3T1rk2USyF1Kd)cg zRh9mX<0Y@#M*$I&M6|p9Dd!B*lPJJM?qLtxr0%)>uytn66C7YM^X9!CNTd_sePVJ- z!6kZ%gCY>^q*eTgkoRY`bcr$o2qk+%VY;y%aliQMOA6Nd&C0t4Z2|L&=?}d?r;4}3 zMLAZ7?l3L_Fg8)BbNuO;#E{>I!K7G4Y5Ion_=q|8d_u0yyhF^r_fWPhw{9*)np1|g zR}E@ySOdWRd{?)&yXZ`r!kVg&P+lxlxV|KL)W2vmV(BTIUe|%BR0Qzi#&r&?ry%lnYQZ$vGt4C9yj?L0TV#Y*BeiR-!WhbDrB5b6!o>qv{}Uo18_|5!}AS7&IeMdqLsJt zqMZvwEnL3SlDtR0%#YRC*7|6cb%2D~qh@mZL+7Nkw-nv97|stpd=6oac&5*@_sh6hN!@U`+NK#{OC4p;R6s;8eM7}Ll);e34=B!9^7;S zmqxEQWKoN;Wy;AVye@J_^icaVX*7@?9~naL zGN3xJ_PU2ziX~V8I)NeFR)B%A2_B0KJA`0huVx;a!p`nfJ~4pbAW&HX=g@^e_N!eQ zaK1u+U|?89k>+MybLfEpK*OG+(J%)D4LfqW1Ae_RCMUf|anJ)Em%2K5Oh9-4I|cfKFQ zt1Ld}4!U`9Lqqs>J4~E+E2(72)pm|-y4W;W7-E;!f0q-9OB_cEeYIXHp|~E2){NF4 z2Xd{&Gg&F2En*H*5m|B_jnd~*6ut&sNGJ)E85BKE-mss)yGAbJ&vyFrIIBzatkxjv ze>6Jz}`sL^P=k1no85h&Ns z4k8$G$e&PKFBu|Qw7}*Jnx6r5if^K7nZ2`$k9TR-S!SRKIH=LssjW^Rrt4B^s!6u> zyXIu_Da1I9CbXE4#}D7Uhs8uTE|4c|I-{Bn?(1Dq8+#SkASiSul5l(&3VQRcQqybc zq?r{4FhGrG`|n5d(`D5-??BWS)-exC=fY)Xc!KxNvQz3Czv6bm3mFl$`(yJ~iL6@c zqsk-!c(3tcaMK5olPe`4k>PB1N4`@yq)qWvSN<&bOc3s=&gBTKG#i#UK6ap#%NHr< z5t|q6wiAl+?lMGTCNt^8_>3^@-{`b#tw0)}sCO@}Ne3+>s74q~`1Rush1e4S8bVS}vk=KqX!52@^RiyfT2;lroN4d;QlzqTq@!21?WZ3ulN9+^}6c z>esyZrmSJk0RDK?ey}4y1M+5Hn{Gb;1pM=TV6DkHCPF;Mx!3Ofygx4tKi*0>P}!Dp zy<6aNlv99gVjDOoo{eL$_}a*=NG)G*?`@KAN=QW-#%ICG?)<49Pb?Fu?jcl1K6ka>7Z3x5KiYpRYvRG!vAz zTYs)jp}(@&Tj^8};HlC`w)zgc2Hd18tGNWgVBjcfBfLWV1yCK+28fe`f)jV-o$i8v zSo`B~&lz@81D8Lj7K)RE$P+^D?E8*!97!07Bke79F^H??N{w@dd|M)uE^T$LQ<=$j zD|dAYnV9z~PU)_dp8>q60_rH3&t(tELxt5>Zy>N?$jJ-+LiL(2ua%LgsclSjb4=5= zbMpN48~T;9N(~&9Y?Qd9y6=D5`mP}^&~dO$)VKK|4TJqlAx!@&xNkXPbo+C;`1tuH zT!z~|JguMhcx+{9d2hg_Q2OP3D)-Y*(MO_HJY7x~*Uw>#0a01;+7)pJEeBguCK8$89+@T+|v(Ue&n(<0}hje2Jq_jf}JdpanO@h_c&2RV_*Y4G+GRe=Ik7SHx*D*Sp99{(nq)xi-N_=9*=LPv&G{xcJN)xNK#EAf2Npt zjk>zJx&kbnK*+A=K%C`VTwJ@n%eab;O5!#H#IPG|>$9_C-IBYXWQcsF_;1p?Cb-0_ z&&B<9%8Xo8PuPs6?wth5*SLRj1pVMO@hX!z#!y10XNfrCL0W{ZAn)8rje5t@}IJL5-l_`+~nI5pVUR%rg$L||~3JYQ<7 zVcKZDpBr-332)@k$LYv$2ygiLx8{~pOl$+O%w#_Ul{%LH>8C%@)Q5xhiGC2&H~Q&5 z%PIaP9O%R1mvB%>(l6ooB^=8SjL#=AoHxIOc2^A3|`9>TQp{Fo9h~bPE=0ZFjzpL^FXW~r4CU`UO zJJCw5$Ts*sYY9Kf?^C6YQEfF7{oek;I1_EN6{5&jTZB=~aOXgCuG(jXL65;UAl6{F zCRHa zx_-S+^vu;f3Nk%_%t0F~AxbiZ^~3rYyc{3utXK;46rWLd-?;s{+D#>ef=m-2(>h+3 zhk_nu=?6tc7v{9bJ9a<&NBYNL(SNMCBJQvng+qCrtP$DGqKa^q>O&&(pd z{NLC8YClvObe&5NY!5_t-v-uZV)XTPiuPS}#4HKv$7i0?p~j(TvBZunE@NZk+5Ign zrq|J58334yC|^(}MF$IKqDK5#?+K-J)Pztx{PG3C$)(MJBKXL?sx?K)IGd&^*iM6P zYxh&ELp=*%k+xGzzpalg8C5|ug<|X_Nn#GQA^)K)pUsJG*2ry_pcco(L?I-l8bsZs z3G?jsg~4YjuhATmFv(YYhrEwqe** ztrrd!$mDm&$)hK^;(G2CA5-cp<3Ewhjw0FsP?J=xf4!xEeTaB!VPRoCb*2w9^-{A8 zsOBpNP++dPQcve|yT{4}y~l9|fW@VRgep*Q!{QPyzhlvT_Q8i9R_@1JR<79BPcWN^ zWA0s^X@*0GcCnaTMvDX|Sm3D^b{Z7t2QkhrzoY3%_i3(`G@?G~6=VC;|I;HtVKOE< zYBW%PVY?rOi#P3w3_}k^%jm!n5fiiM9RGZF`bX=hZ%M3>nQ!&;^K&$CV~9uZNDK>= z92# zks3he>RMZ8x#7npj)fiJXrj=<08GMh(y5cjxXa4Q)R?koe&1bk2QACN**I)}->?S7 zQcbbj<(j^CEqo%Jp^4>mAM*+dX3Ja(7Z1I0 z1}`Y8aV{5ZB*H?N7>YF9N9v}grV%I||4G7O&d_b7#^=PQ@13%mMwkVv=RSUFn7O)! zhKA0jm_};~PCk$#`4&iZZ$}MG%V;e*25DGcG&D3${##2nsW$M}dYDwB*9tY~rJkC4 z7N=}%`f4gGE034iJ)V4qrM$5nL}8daSM4dr4o)itT-g-@yJn(lZ6}nvGg5Rf%e^tq zt2PZnbAqB1EWI*Oj^8UqqPl8qt_h+{NB`JVQAN>a>=E4X8Qc5&Dfo?HAU+!&9yWR^ zf|pz8Upcy- ng1cbQaPzP$P^psSH1HxnpDv{5!Bu_n+zN78m~yRSnCn literal 0 HcmV?d00001 diff --git a/docs/static/images/deployment.png b/docs/static/images/deployment.png new file mode 100644 index 0000000000000000000000000000000000000000..a33044a79f8b8127f238da5b594560671d8f4498 GIT binary patch literal 233594 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%0A(s2OZ(nm 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 W+3R)kQ1>$UCw)}z*XUog|M)-UWJvx1 literal 0 HcmV?d00001 diff --git a/examples/go-demo/ffi/Cargo.toml b/examples/go-demo/ffi/Cargo.toml index 1cd1e60c..eca27a4f 100644 --- a/examples/go-demo/ffi/Cargo.toml +++ b/examples/go-demo/ffi/Cargo.toml @@ -25,3 +25,6 @@ cpex-ffi = { path = "../../../crates/cpex-ffi" } async-trait = "0.1" serde_json = "1" tracing = "0.1" + +[lints] +workspace = true diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index 4cbbf53c..04d76fab 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -79,14 +79,14 @@ impl HookHandler for IdentityChecker { Some(u) if !u.is_empty() => { tracing::info!("[identity-checker] OK: user '{}' identified", u); PluginResult::allow() - } + }, _ => { tracing::warn!("[identity-checker] DENIED: no user identity"); PluginResult::deny(PluginViolation::new( "no_identity", "User identity is required", )) - } + }, } } } diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index fe1c324a..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,167 +0,0 @@ -[project] -name = "cpex" -version = "0.1.1" -description = "CPEX - ContextForge Plugin Extensibility Framework" -classifiers = [ - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Topic :: Software Development :: Libraries :: Python Modules", -] -license = "Apache-2.0" -license-files = ["LICENSE"] -readme = "README.md" -authors = [ - { name = "Fred Araujo", email = "frederico.araujo@ibm.com" }, - { name = "Mihai Criveti", email = "redacted@ibm.com"}, - { name = "Teryl Taylor", email = "terylt@ibm.com" }, -] -maintainers = [ - { name = "Fred Araujo", email = "frederico.araujo@ibm.com" }, - { name = "Jonathan Springer", email = "redacted@ibm.com" }, - { name = "Teryl Taylor", email = "terylt@ibm.com" }, -] -requires-python = ">=3.11" -dependencies = [ - "fastapi>=0.133.1", - "httpx>=0.28.1", - "httpx[http2]>=0.28.1", - "jinja2>=3.1.6", - "mcp>=1.26.0", - "orjson>=3.11.7", - "prometheus-fastapi-instrumentator>=7.1.0", - "prometheus_client>=0.24.1", - "pydantic-settings>=2.13.1", - "pydantic>=2.12.5", - "pyyaml>=6.0.3", - "packaging>=26.0", - "inquirer>=3.4.1", - "rich>=14.3.3", - "pygithub>=2.9.0" -] - -[project.scripts] -cpex = "cpex.tools.cli:main" - -[project.urls] -Repository = "https://github.com/contextforge-org/cpex" - -[project.optional-dependencies] - -# dev dependencies -dev = [ - "bandit>=1.8.6", - "check-manifest>=0.50", - "interrogate>=1.7.0", - "mypy>=1.18.2", - "pyroma>=5.0", - "pytest-asyncio>=1.2.0", - "pytest-cov>=7.0.0", - "pytest-env>=1.1.5", - "pytest-xdist>=3.8.0", - "pytest>=8.4.2", - "radon>=6.0.1", - "ruff>=0.13.3", - "twine>=6.2.0", - "types-PyYAML>=6.0.0", - "vulture>=2.14", -] - -# documentation support -# The doc site uses Hugo (brew install hugo) — no Python deps required. -# This group is reserved for future API reference generation (e.g., pdoc, sphinx). -docs = [] - -# gRPC transport support (higher performance than MCP/HTTP) -grpc = [ - "grpcio-tools>=1.70.0", - "grpcio>=1.70.0", - "protobuf>=5.29.0", -] - -# CLI dependencies -cli = [ - "chardet>=3.0.2,<6", # pin to range supported by requests (binaryornot pulls chardet 7+) - "cookiecutter>=2.6.0", - "typer>=0.21.2", # docling requires typer < 0.22 -] - -# All extras -all = ["cpex[grpc,cli]"] - -[tool.interrogate] -exclude = ["cpex/templates", "*_pb2.py", "*_pb2_grpc.py"] -fail-under = 100 - -[tool.radon] -exclude = "cpex/templates/*,*_pb2.py,*_pb2_grpc.py" - -[tool.vulture] -min_confidence = 80 -exclude = ["cpex/templates", "cpex/framework/observability.py", "*_pb2.py", "*_pb2_grpc.py"] - -[tool.ruff] -line-length = 120 -exclude = [ - "cpex/templates", -] - -[tool.check-manifest] -ignore = [ - "docs/**", - "tests/**", - ".github/**", - "Makefile", -] - -# Linter configuration -[tool.ruff.lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -# Also "D1" for docstring present checks. -# "PL" enables Pylint rules natively in ruff (replaces pylint for these checks). -select = ["E3", "E4", "E7", "E9", "F", "D1", "PL", "I"] -ignore = [ - # ---- Rules already disabled in .pylintrc (intentionally relaxed) ---- - "PLR0904", # too-many-public-methods (pylint R0904) - "PLR0912", # too-many-branches (pylint R0912) - "PLR0913", # too-many-arguments (pylint R0913) - "PLR0914", # too-many-locals (pylint R0914) - "PLR0915", # too-many-statements (pylint R0915) - # ---- Accepted codebase patterns (high violation count, tolerated by pylint scoring) ---- - "PLC0415", # import-outside-top-level (pylint C0415) — lazy-import patterns - "PLW0603", # global-statement (pylint W0603) — config/singleton patterns - "PLR0911", # too-many-returns - "PLR0917", # too-many-positional-args - "PLR1702", # too-many-nested-blocks - # ---- Ruff-only PL rules with no pylint equivalent (out of migration scope) ---- - "PLC1901", # compare-to-empty-string (deprecated in pylint) - "PLC2701", # import-private-name (ruff-only) - "PLR2004", # magic-value-comparison (ruff-only) - "PLR5501", # collapsible-else-if (ruff-only) - "PLR6104", # non-augmented-assignment (ruff-only) - "PLR6201", # literal-membership (ruff-only) - "PLR6301", # no-self-use (ruff-only) - "PLW2901", # redefined-loop-name (ruff-only) -] - -preview = true - -# Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -[tool.ruff.lint.pylint] -# Relaxed from the default of 5; existing code has wider try clauses (max observed 38). -max-statements-in-try = 50 - -# Ignore D1 (docstring checks) and Pylint checks in tests and other non-production code -[tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["D1", "PL"] -"scripts/**/*.py" = ["D1", "PL"] -".github/**/*.py" = ["D1", "PL"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["cpex"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..39e4938d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Pin the toolchain so local builds, CI, and the release pipeline all agree. +# This version is also the project MSRV (mirrored in clippy.toml `msrv` and the +# `msrv` CI check). Bump all three together. +[toolchain] +channel = "1.96.0" +components = ["clippy", "rustfmt", "rust-analyzer", "llvm-tools-preview"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..e9794a8d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,12 @@ +# rustfmt configuration — stable-channel options only, so `cargo fmt` runs on +# the pinned stable toolchain (see rust-toolchain.toml) with no nightly needed. +# +# 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" +max_width = 100 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +match_block_trailing_comma = true diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 08de03f8..00000000 --- a/tests/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/__init__.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests Package. -""" diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 424fe692..00000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,14 +0,0 @@ -[pytest] -log_cli = false -log_cli_level = INFO -log_cli_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s -log_cli_date_format = %Y-%m-%d %H:%M:%S -log_level = INFO -log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s -log_date_format = %Y-%m-%d %H:%M:%S -; addopts = --cov=cpex --cov-report=term-missing -env_files = .env -pythonpath = . -filterwarnings = - ignore::DeprecationWarning:pydantic.* - ignore::DeprecationWarning:pythonjsonlogger.* diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index 2f55c728..00000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/__init__.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests Package. -""" diff --git a/tests/unit/cpex/__init__.py b/tests/unit/cpex/__init__.py deleted file mode 100644 index 58ddedc0..00000000 --- a/tests/unit/cpex/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/__init__.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests Package. -""" diff --git a/tests/unit/cpex/conftest.py b/tests/unit/cpex/conftest.py deleted file mode 100644 index 7480f7b1..00000000 --- a/tests/unit/cpex/conftest.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/conftest.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Pytest fixtures for plugin framework tests. -""" - -# Third-Party -import pytest - -# First-Party -import cpex.framework as fw -from cpex.framework import PluginManager -from cpex.framework.settings import settings - - -@pytest.fixture(autouse=True) -def reset_plugin_manager_state(): - """Reset PluginManager Borg state before and after each test. - - This ensures each test starts with a fresh PluginManager instance, - preventing state leakage between tests when using the Borg pattern. - Also resets the module-level singleton cached by get_plugin_manager(). - """ - PluginManager.reset() - fw._plugin_manager = None - yield - PluginManager.reset() - fw._plugin_manager = None - - -@pytest.fixture(autouse=True) -def clear_plugins_settings_cache(reset_plugin_manager_state): - """Clear the settings LRU cache so env changes take effect per test.""" - settings.cache_clear() - yield - settings.cache_clear() diff --git a/tests/unit/cpex/fixtures/__init__.py b/tests/unit/cpex/fixtures/__init__.py deleted file mode 100644 index c20784c4..00000000 --- a/tests/unit/cpex/fixtures/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/__init__.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests Package. -""" diff --git a/tests/unit/cpex/fixtures/common/__init__.py b/tests/unit/cpex/fixtures/common/__init__.py deleted file mode 100644 index 7ccba9b7..00000000 --- a/tests/unit/cpex/fixtures/common/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/common/__init__.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo -""" diff --git a/tests/unit/cpex/fixtures/common/models.py b/tests/unit/cpex/fixtures/common/models.py deleted file mode 100644 index 27ff41ff..00000000 --- a/tests/unit/cpex/fixtures/common/models.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/common/models.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -MCP Protocol Type Definitions for tests. -""" - -# Standard -from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Union - -# Third-Party -from pydantic import BaseModel, Field - - -class Role(str, Enum): - """Message role in conversations.""" - - ASSISTANT = "assistant" - USER = "user" - - -# Base content types -class TextContent(BaseModel): - """Text content for messages (MCP spec-compliant).""" - - type: Literal["text"] - text: str - annotations: Optional[Any] = None - meta: Optional[Dict[str, Any]] = Field(None, alias="_meta") - - -class ResourceContents(BaseModel): - """Base class for resource contents (MCP spec-compliant).""" - - uri: str - mime_type: Optional[str] = Field(None, alias="mimeType") - meta: Optional[Dict[str, Any]] = Field(None, alias="_meta") - - -# Legacy ResourceContent for backwards compatibility -class ResourceContent(BaseModel): - """Resource content that can be embedded (LEGACY - use TextResourceContents or BlobResourceContents).""" - - type: Literal["resource"] - id: str - uri: str - mime_type: Optional[str] = None - text: Optional[str] = None - blob: Optional[bytes] = None - - -ContentType = Union[TextContent, ResourceContent] - - -# Message types -class Message(BaseModel): - """A message in a conversation. - - Attributes: - role (Role): The role of the message sender. - content (ContentType): The content of the message. - """ - - role: Role - content: ContentType - - -class PromptMessage(BaseModel): - """Message in a prompt (MCP spec-compliant).""" - - role: Role - content: "ContentBlock" # Uses ContentBlock union (includes ResourceLink and EmbeddedResource) - - -class PromptResult(BaseModel): - """Result of rendering a prompt template. - - Attributes: - messages (List[Message]): The list of messages produced by rendering the prompt. - description (Optional[str]): An optional description of the rendered result. - """ - - messages: List[Message] - description: Optional[str] = None - - -# MCP spec-compliant ContentBlock union for prompts and tool results -# Per spec: ContentBlock can include ResourceLink and EmbeddedResource -ContentBlock = Union[TextContent] diff --git a/tests/unit/cpex/fixtures/common/policy.py b/tests/unit/cpex/fixtures/common/policy.py deleted file mode 100644 index f00498b3..00000000 --- a/tests/unit/cpex/fixtures/common/policy.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/common/policy.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Concrete hook payload policies for testing. -""" - -# First-Party -from cpex.framework.hooks.policies import HookPayloadPolicy - -HOOK_PAYLOAD_POLICIES: dict[str, HookPayloadPolicy] = { - # Tools - "tool_pre_invoke": HookPayloadPolicy(writable_fields=frozenset({"name", "args", "headers"})), - "tool_post_invoke": HookPayloadPolicy(writable_fields=frozenset({"result"})), - # Prompts - "prompt_pre_fetch": HookPayloadPolicy(writable_fields=frozenset({"args"})), - "prompt_post_fetch": HookPayloadPolicy(writable_fields=frozenset({"result"})), - # Resources - "resource_pre_fetch": HookPayloadPolicy(writable_fields=frozenset({"uri", "metadata"})), - "resource_post_fetch": HookPayloadPolicy(writable_fields=frozenset({"content"})), - # Agents - "agent_pre_invoke": HookPayloadPolicy( - writable_fields=frozenset({"agent_id", "messages", "tools", "model", "system_prompt", "parameters", "headers"}) - ), - "agent_post_invoke": HookPayloadPolicy(writable_fields=frozenset({"messages", "tool_calls"})), - # HTTP hooks (cross-type results — input and output payload types differ, - # so field-level filtering is not applicable; policy presence authorises - # the hook so it is never subject to default_hook_policy=deny). - "http_pre_request": HookPayloadPolicy(writable_fields=frozenset({"headers"})), - "http_post_request": HookPayloadPolicy(writable_fields=frozenset({"headers"})), - "http_auth_resolve_user": HookPayloadPolicy(writable_fields=frozenset()), - "http_auth_check_permission": HookPayloadPolicy(writable_fields=frozenset({"reason"})), -} diff --git a/tests/unit/cpex/fixtures/configs/agent_context.yaml b/tests/unit/cpex/fixtures/configs/agent_context.yaml deleted file mode 100644 index 28b19faf..00000000 --- a/tests/unit/cpex/fixtures/configs/agent_context.yaml +++ /dev/null @@ -1,27 +0,0 @@ -plugins: - - name: ContextTrackingAgent - kind: plugins.agent_plugins.ContextTrackingAgentPlugin - description: An agent plugin that tracks state in local context - version: "1.0.0" - author: Test Suite - hooks: - - agent_pre_invoke - - agent_post_invoke - tags: - - test - - agent - - context - mode: concurrent - priority: 50 - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/agent_filter.yaml b/tests/unit/cpex/fixtures/configs/agent_filter.yaml deleted file mode 100644 index 4ddd1644..00000000 --- a/tests/unit/cpex/fixtures/configs/agent_filter.yaml +++ /dev/null @@ -1,32 +0,0 @@ -plugins: - - name: MessageFilterAgent - kind: plugins.agent_plugins.MessageFilterAgentPlugin - description: An agent plugin that filters blocked words - version: "1.0.0" - author: Test Suite - hooks: - - agent_pre_invoke - - agent_post_invoke - tags: - - test - - agent - - filter - mode: concurrent - priority: 50 - config: - blocked_words: - - spam - - malware - - phishing - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/agent_passthrough.yaml b/tests/unit/cpex/fixtures/configs/agent_passthrough.yaml deleted file mode 100644 index c6c4d75e..00000000 --- a/tests/unit/cpex/fixtures/configs/agent_passthrough.yaml +++ /dev/null @@ -1,26 +0,0 @@ -plugins: - - name: PassThroughAgent - kind: plugins.agent_plugins.PassThroughAgentPlugin - description: A simple pass-through agent plugin for testing - version: "1.0.0" - author: Test Suite - hooks: - - agent_pre_invoke - - agent_post_invoke - tags: - - test - - agent - mode: concurrent - priority: 50 - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/context_multiplugins.yaml b/tests/unit/cpex/fixtures/configs/context_multiplugins.yaml deleted file mode 100644 index c70da9a4..00000000 --- a/tests/unit/cpex/fixtures/configs/context_multiplugins.yaml +++ /dev/null @@ -1,42 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "ContextPlugin" - kind: "plugins.context.ContextPlugin" - description: "A context plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] - tags: ["plugin", "error"] - mode: "sequential" - priority: 100 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - - name: "ContextPlugin2" - kind: "plugins.context.ContextPlugin2" - description: "A context plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] - tags: ["plugin", "error"] - mode: "audit" # Runs after sequential phase; sees globkey1 set by ContextPlugin - priority: 200 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/context_plugin.yaml b/tests/unit/cpex/fixtures/configs/context_plugin.yaml deleted file mode 100644 index 1174c743..00000000 --- a/tests/unit/cpex/fixtures/configs/context_plugin.yaml +++ /dev/null @@ -1,28 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "ContextPlugin" - kind: "plugins.context.ContextPlugin" - description: "A context plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] - tags: ["plugin", "error"] - mode: "concurrent" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/context_stdio_external_plugins.yaml b/tests/unit/cpex/fixtures/configs/context_stdio_external_plugins.yaml deleted file mode 100644 index 71ce5cfa..00000000 --- a/tests/unit/cpex/fixtures/configs/context_stdio_external_plugins.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "ContextPlugin" - kind: "external" - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - - name: "ContextPlugin2" - kind: "external" - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/cross_hook_context.yaml b/tests/unit/cpex/fixtures/configs/cross_hook_context.yaml deleted file mode 100644 index d838bf2e..00000000 --- a/tests/unit/cpex/fixtures/configs/cross_hook_context.yaml +++ /dev/null @@ -1,26 +0,0 @@ -plugins: - - name: "CrossHookContextPlugin" - kind: "plugins.cross_hook_context.CrossHookContextPlugin" - description: "Test plugin that demonstrates cross-hook context sharing" - version: "1.0.0" - author: "Test Author" - hooks: - - "http_pre_request" - - "http_auth_check_permission" - - "tool_pre_invoke" - - "resource_pre_fetch" - - "prompt_pre_fetch" - tags: ["test", "cross-hook", "context-sharing"] - mode: "concurrent" - priority: 50 - config: {} - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -plugin_settings: - parallel_execution_within_band: false - plugin_timeout: 30 - fail_on_plugin_error: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/error_plugin.yaml b/tests/unit/cpex/fixtures/configs/error_plugin.yaml deleted file mode 100644 index ab6397fb..00000000 --- a/tests/unit/cpex/fixtures/configs/error_plugin.yaml +++ /dev/null @@ -1,28 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "ErrorPlugin" - kind: "plugins.error.ErrorPlugin" - description: "An error plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] - tags: ["plugin", "error"] - mode: "concurrent" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/error_plugin_raise_error_false.yaml b/tests/unit/cpex/fixtures/configs/error_plugin_raise_error_false.yaml deleted file mode 100644 index feaaeac4..00000000 --- a/tests/unit/cpex/fixtures/configs/error_plugin_raise_error_false.yaml +++ /dev/null @@ -1,28 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "ErrorPlugin" - kind: "plugins.error.ErrorPlugin" - description: "An error plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] - tags: ["plugin", "error"] - mode: "concurrent" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/error_stdio_external_plugin.yaml b/tests/unit/cpex/fixtures/configs/error_stdio_external_plugin.yaml deleted file mode 100644 index 7e5b0b3c..00000000 --- a/tests/unit/cpex/fixtures/configs/error_stdio_external_plugin.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "ErrorPlugin" - kind: "external" - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml b/tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml deleted file mode 100644 index 7081cc14..00000000 --- a/tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml +++ /dev/null @@ -1,24 +0,0 @@ -plugins: - - name: "ExtensionsAwarePlugin" - kind: "plugins.extensions_aware.ExtensionsAwarePlugin" - description: "Test plugin that accepts extensions" - author: "Test" - version: "0.1" - hooks: - - "tool_pre_invoke" - mode: "sequential" - priority: 10 - capabilities: - - "read_subject" - - "read_roles" - - "read_labels" - -plugin_dirs: - - "./tests/unit/cpex/fixtures" - -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/extensions_custom_plugin.yaml b/tests/unit/cpex/fixtures/configs/extensions_custom_plugin.yaml deleted file mode 100644 index d2f824a3..00000000 --- a/tests/unit/cpex/fixtures/configs/extensions_custom_plugin.yaml +++ /dev/null @@ -1,22 +0,0 @@ -plugins: - - name: "ExtensionsCustomPlugin" - kind: "plugins.extensions_aware.ExtensionsCustomPlugin" - description: "Test plugin that reads labels and writes custom extensions" - author: "Test" - version: "0.1" - hooks: - - "tool_pre_invoke" - mode: "sequential" - priority: 10 - capabilities: - - "read_labels" - -plugin_dirs: - - "./tests/unit/cpex/fixtures" - -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/extensions_label_plugin.yaml b/tests/unit/cpex/fixtures/configs/extensions_label_plugin.yaml deleted file mode 100644 index 78dd1790..00000000 --- a/tests/unit/cpex/fixtures/configs/extensions_label_plugin.yaml +++ /dev/null @@ -1,23 +0,0 @@ -plugins: - - name: "ExtensionsLabelPlugin" - kind: "plugins.extensions_aware.ExtensionsLabelPlugin" - description: "Test plugin that adds labels to extensions" - author: "Test" - version: "0.1" - hooks: - - "tool_pre_invoke" - mode: "sequential" - priority: 10 - capabilities: - - "read_labels" - - "append_labels" - -plugin_dirs: - - "./tests/unit/cpex/fixtures" - -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/init_hooks_plugins_test.yaml b/tests/unit/cpex/fixtures/configs/init_hooks_plugins_test.yaml deleted file mode 100644 index 0c3d5896..00000000 --- a/tests/unit/cpex/fixtures/configs/init_hooks_plugins_test.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# init_hook_plugins_test.yaml - Test configuration for plugins -# This config enables each in audit mode for testing instantiation and hook invocation - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 120 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 120 - -plugins: - - # Self-contained Search Replace Plugin - - name: "ReplaceBadWordsPlugin" - kind: "plugins.search_replace.SearchReplacePlugin" - description: "A plugin for finding and replacing words." - version: "0.1.0" - author: "Mihai Criveti" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] - mode: "audit" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: crap - replace: crud - - search: crud - replace: yikes - - # Deny List - - name: "DenyListPlugin" - kind: "plugins.deny_filter.DenyListPlugin" - description: "A plugin that implements a deny list filter." - version: "0.1.0" - author: "Mihai Criveti" - hooks: ["prompt_pre_fetch"] - tags: ["plugin", "filter", "denylist", "pre-post"] - mode: "audit" # enforce | audit | disabled - priority: 100 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - innovative - - groundbreaking - - revolutionary - - # Resource Filter Plugin - Example of resource hooks - - name: "ResourceFilterExample" - kind: "plugins.resource_filter.resource_filter.ResourceFilterPlugin" - description: "Demonstrates resource pre/post fetch hooks for filtering and validation" - version: "1.0.0" - author: "ContextForge Team" - hooks: ["resource_pre_fetch", "resource_post_fetch"] - tags: ["resource", "filter", "security", "example"] - mode: "audit" # Block resources that violate rules - priority: 75 - conditions: [] # Apply to all resources - config: - # Maximum content size in bytes (1MB) - max_content_size: 1048576 - # Allowed protocols (removing file for testing) - allowed_protocols: - - test - - time - - timezone - - http - - https - # Blocked domains (examples) - blocked_domains: - - malicious.example.com - - untrusted-site.net - # Content filters to redact sensitive data - content_filters: - - pattern: "password\\s*[:=]\\s*\\S+" - replacement: "password: [REDACTED]" - - pattern: "api[_-]?key\\s*[:=]\\s*\\S+" - replacement: "api_key: [REDACTED]" - - pattern: "secret\\s*[:=]\\s*\\S+" - replacement: "secret: [REDACTED]" diff --git a/tests/unit/cpex/fixtures/configs/invalid_single_plugin.yaml b/tests/unit/cpex/fixtures/configs/invalid_single_plugin.yaml deleted file mode 100644 index 4775e8c3..00000000 --- a/tests/unit/cpex/fixtures/configs/invalid_single_plugin.yaml +++ /dev/null @@ -1,35 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "FakePlugin" - kind: "some.fake.nonexistentPlugin" - description: "A plugin for finding and replacing words." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] - mode: "concurrent" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: crap - replace: crud - - search: crud - replace: yikes - - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml deleted file mode 100644 index a486ce72..00000000 --- a/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Plugin directories to scan -plugin_dirs: -- "tests/unit/cpex/fixtures/plugins/isolated" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 - - -plugins: - - name: "test_plugin" - kind: "isolated_venv" - description: "A framework testing filter plugin" - version: "0.1.0" - author: "habeck" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin"] - mode: "sequential" # enforce | permissive | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - # Plugin config dict passed to the plugin constructor - class_name: "test_plugin.plugin.TestPlugin" - requirements_file: "requirements.txt" - # essentially the plugin folder hosting the plugin - script_path: "tests/unit/cpex/fixtures/plugins/isolated" - diff --git a/tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml b/tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml deleted file mode 100644 index d827505c..00000000 --- a/tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -plugins: - - name: DemoPlugin - kind: test_hook_patterns.DemoPlugin - description: Demonstration plugin showing all three hook patterns - version: "1.0.0" - author: Demo - hooks: - - tool_pre_invoke - - tool_post_invoke - - email_pre_send - tags: - - demo - - test - mode: transform - priority: 50 - -# Plugin directories to scan (not needed for this demo) -plugin_dirs: [] - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/tool_headers_metadata_plugin.yaml b/tests/unit/cpex/fixtures/configs/tool_headers_metadata_plugin.yaml deleted file mode 100644 index b9e474a6..00000000 --- a/tests/unit/cpex/fixtures/configs/tool_headers_metadata_plugin.yaml +++ /dev/null @@ -1,28 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "HeadersMetaDataPlugin" - kind: "plugins.headers.HeadersMetaDataPlugin" - description: "A tools header plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin", "headers"] - mode: "concurrent" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: [] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/tool_headers_plugin.yaml b/tests/unit/cpex/fixtures/configs/tool_headers_plugin.yaml deleted file mode 100644 index fa77432a..00000000 --- a/tests/unit/cpex/fixtures/configs/tool_headers_plugin.yaml +++ /dev/null @@ -1,28 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "HeadersPlugin" - kind: "plugins.headers.HeadersPlugin" - description: "A tools header plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["tool_pre_invoke"] - tags: ["plugin", "headers"] - mode: "transform" # transform: can modify payloads, cannot block - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: [] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: true - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml b/tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml deleted file mode 100644 index 1204d068..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# gRPC external plugin client configuration -plugins: - - name: "ReplaceBadWordsPlugin" - kind: "external" - grpc: - target: "127.0.0.1:50051" - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: false - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin_manager.yaml b/tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin_manager.yaml deleted file mode 100644 index 787fba5c..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin_manager.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# gRPC external plugin configuration for PluginManager tests -# Note: The port will be dynamically updated by the test fixture -plugins: - - name: "ReplaceBadWordsPlugin" - kind: "external" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - mode: "transform" - priority: 100 - grpc: - target: "127.0.0.1:50151" - -# Plugin directories to scan -plugin_dirs: [] - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: false - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml b/tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml deleted file mode 100644 index da2e6ed3..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml +++ /dev/null @@ -1,55 +0,0 @@ -plugins: - - name: "SynonymsPlugin" - kind: "plugins.search_replace.SearchReplacePlugin" - description: "A plugin for finding and replacing synonyms." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] - mode: "transform" # transform: can modify payloads, cannot block - priority: 149 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: happy - replace: gleeful - - search: sad - replace: sullen - # Self-contained Search Replace Plugin - - name: "ReplaceBadWordsPlugin" - kind: "plugins.search_replace.SearchReplacePlugin" - description: "A plugin for finding and replacing words." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] - mode: "transform" # transform: can modify payloads, cannot block - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: crap - replace: crud - - search: crud - replace: yikes - - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml b/tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml deleted file mode 100644 index 4a6fe4ee..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml +++ /dev/null @@ -1,87 +0,0 @@ -plugins: - # Self-contained Deny List Plugin - - name: "DenyListPlugin" - kind: "plugins.deny_filter.DenyListPlugin" - description: "A plugin that implements a deny list filter." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch"] - tags: ["plugin", "filter", "denylist", "pre-post"] - mode: "sequential" # sequential: can block and modify - priority: 100 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - innovative - - groundbreaking - - revolutionary - # Self-contained Search Replace Plugin - - name: "ReplaceBadWordsPlugin" - kind: "plugins.search_replace.SearchReplacePlugin" - description: "A plugin for finding and replacing words." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] - mode: "transform" # transform: can modify payloads, cannot block - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: crap - replace: crud - - search: crud - replace: yikes - - name: "ResourceFilterExample" - kind: "plugins.resource_filter.ResourceFilterPlugin" - description: "Demonstrates resource pre/post fetch hooks for filtering and validation" - version: "1.0.0" - author: "ContextForge Team" - hooks: ["resource_pre_fetch", "resource_post_fetch"] - tags: ["resource", "filter", "security", "example"] - mode: "sequential" # Block resources that violate rules - priority: 200 - conditions: [] # Apply to all resources - config: - # Maximum content size in bytes (1MB) - max_content_size: 1048576 - # Allowed protocols (removing file for testing) - allowed_protocols: - - test - - time - - timezone - - http - - https - # Blocked domains (examples) - blocked_domains: - - malicious.example.com - - untrusted-site.net - # Content filters to redact sensitive data - content_filters: - - pattern: "password\\s*[:=]\\s*\\S+" - replacement: "password: [REDACTED]" - - pattern: "api[_-]?key\\s*[:=]\\s*\\S+" - replacement: "api_key: [REDACTED]" - - pattern: "secret\\s*[:=]\\s*\\S+" - replacement: "secret: [REDACTED]" - - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml b/tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml deleted file mode 100644 index 10854ce7..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml +++ /dev/null @@ -1,14 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_single_filter_plugin.yaml b/tests/unit/cpex/fixtures/configs/valid_single_filter_plugin.yaml deleted file mode 100644 index 310291b8..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_single_filter_plugin.yaml +++ /dev/null @@ -1,34 +0,0 @@ -plugins: - # Self-contained Deny List Plugin - - name: "DenyListPlugin" - kind: "plugins.deny_filter.DenyListPlugin" - description: "A plugin that implements a deny list filter." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch"] - tags: ["plugin", "filter", "denylist", "pre-post"] - mode: "sequential" # sequential: can block and modify - priority: 100 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - innovative - - groundbreaking - - revolutionary - - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml b/tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml deleted file mode 100644 index d34bc6fc..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml +++ /dev/null @@ -1,35 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "ReplaceBadWordsPlugin" - kind: "plugins.search_replace.SearchReplacePlugin" - description: "A plugin for finding and replacing words." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] - mode: "transform" # transform: can modify payloads, cannot block - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: ["test_prompt"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: crap - replace: crud - - search: crud - replace: yikes - - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_single_plugin_passthrough.yaml b/tests/unit/cpex/fixtures/configs/valid_single_plugin_passthrough.yaml deleted file mode 100644 index 153287a9..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_single_plugin_passthrough.yaml +++ /dev/null @@ -1,28 +0,0 @@ -plugins: - # Self-contained Search Replace Plugin - - name: "PassThroughPlugin" - kind: "plugins.passthrough.PassThroughPlugin" - description: "A simple passthrough plugin." - version: "0.1" - author: "ContextForge Team" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke", "resource_pre_fetch", "resource_post_fetch"] - tags: ["plugin", "passthrough"] - mode: "concurrent" # enforce | audit | disabled - priority: 150 - conditions: - # Apply to specific tools/servers - - prompts: [] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml b/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml deleted file mode 100644 index 3034118b..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "DenyListPlugin" - kind: "external" - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_overrides.yaml b/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_overrides.yaml deleted file mode 100644 index ece32a2a..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_overrides.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "DenyListPlugin" - kind: "external" - description: "a different configuration." - priority: 150 - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_passthrough.yaml b/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_passthrough.yaml deleted file mode 100644 index 07880d9b..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_passthrough.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "PassThroughPlugin" - kind: "external" - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_regex.yaml b/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_regex.yaml deleted file mode 100644 index df646a1f..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_regex.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "ReplaceBadWordsPlugin" - kind: "external" - mcp: - proto: STDIO - script: cpex/framework/external/mcp/server/runtime.py - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_overrides.yaml b/tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_overrides.yaml deleted file mode 100644 index 18136e79..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_overrides.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "DenyListPlugin" - kind: "external" - description: "a different configuration." - priority: 150 - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - mcp: - proto: STREAMABLEHTTP - url: http://localhost:3001/mcp - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml b/tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml deleted file mode 100644 index 56b525a4..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# plugins/config.yaml - Main plugin configuration file - -plugins: - - name: "ReplaceBadWordsPlugin" - kind: "external" - mcp: - proto: STREAMABLEHTTP - url: http://localhost:3001/mcp - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_tool_hooks.yaml b/tests/unit/cpex/fixtures/configs/valid_tool_hooks.yaml deleted file mode 100644 index 2d89e9fa..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_tool_hooks.yaml +++ /dev/null @@ -1,35 +0,0 @@ -plugins: - # Test plugin for tool hooks - - name: "ToolTestPlugin" - kind: "plugins.search_replace.SearchReplacePlugin" - description: "A plugin for testing tool hooks." - version: "0.1" - author: "ContextForge Team" - hooks: ["tool_pre_invoke", "tool_post_invoke"] - tags: ["plugin", "transformer", "regex", "search-and-replace", "tool-hooks"] - mode: "transform" # transform: can modify payloads, cannot block - priority: 150 - conditions: - # Apply to specific tools - - tools: ["test_tool"] - server_ids: [] # Apply to all servers - tenant_ids: [] # Apply to all tenants - config: - words: - - search: bad - replace: good - - search: wrong - replace: right - - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: true - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml b/tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml deleted file mode 100644 index f6f9dcd3..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Unix socket external plugin client configuration -plugins: - - name: "ReplaceBadWordsPlugin" - kind: "external" - unix_socket: - path: "/tmp/cpex-test.sock" - timeout: 30.0 - reconnect_attempts: 3 - reconnect_delay: 0.1 - -# Plugin directories to scan -plugin_dirs: - - "tests/unit/cpex/fixtures" - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: false - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml b/tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml deleted file mode 100644 index 65079107..00000000 --- a/tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Unix socket external plugin configuration for PluginManager tests -plugins: - - name: "ReplaceBadWordsPlugin" - kind: "external" - hooks: ["prompt_pre_fetch", "prompt_post_fetch"] - mode: "transform" - priority: 100 - unix_socket: - path: "/tmp/cpex-pm-test.sock" - timeout: 30.0 - reconnect_attempts: 3 - reconnect_delay: 0.1 - -# Plugin directories to scan -plugin_dirs: [] - -# Global plugin settings -plugin_settings: - parallel_execution_within_band: false - plugin_timeout: 30 - fail_on_plugin_error: false - enable_plugin_api: true - plugin_health_check_interval: 60 diff --git a/tests/unit/cpex/fixtures/plugins/__init__.py b/tests/unit/cpex/fixtures/plugins/__init__.py deleted file mode 100644 index d5dfb35d..00000000 --- a/tests/unit/cpex/fixtures/plugins/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor -""" diff --git a/tests/unit/cpex/fixtures/plugins/agent_plugins.py b/tests/unit/cpex/fixtures/plugins/agent_plugins.py deleted file mode 100644 index 185b4775..00000000 --- a/tests/unit/cpex/fixtures/plugins/agent_plugins.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/agent_plugins.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Test agent plugins for unit testing. -""" - -# First-Party -from cpex.framework import ( - AgentPostInvokePayload, - AgentPostInvokeResult, - AgentPreInvokePayload, - AgentPreInvokeResult, - Plugin, - PluginContext, -) - - -class PassThroughAgentPlugin(Plugin): - """A simple pass-through agent plugin that doesn't modify anything.""" - - async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context: PluginContext) -> AgentPreInvokeResult: - """Pass through without modification. - - Args: - payload: The agent pre-invoke payload. - context: Contextual information about the hook call. - - Returns: - The result allowing processing to continue. - """ - return AgentPreInvokeResult(continue_processing=True) - - async def agent_post_invoke(self, payload: AgentPostInvokePayload, context: PluginContext) -> AgentPostInvokeResult: - """Pass through without modification. - - Args: - payload: The agent post-invoke payload. - context: Contextual information about the hook call. - - Returns: - The result allowing processing to continue. - """ - return AgentPostInvokeResult(continue_processing=True) - - -class MessageFilterAgentPlugin(Plugin): - """An agent plugin that filters messages containing blocked words.""" - - async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context: PluginContext) -> AgentPreInvokeResult: - """Filter messages containing blocked words. - - Args: - payload: The agent pre-invoke payload. - context: Contextual information about the hook call. - - Returns: - The result with filtered messages or violation. - """ - blocked_words = self.config.config.get("blocked_words", []) - - # Filter messages - filtered_messages = [] - for msg in payload.messages: - if hasattr(msg.content, "text") and isinstance(msg.content, str): - text_lower = msg.content.text.lower() - if any(word in text_lower for word in blocked_words): - # Skip this message - continue - filtered_messages.append(msg) - - # If all messages were blocked, return violation - if not filtered_messages and payload.messages: - from mcpgateway.plugins.framework import PluginViolation - - return AgentPreInvokeResult( - continue_processing=False, - violation=PluginViolation( - code="BLOCKED_CONTENT", - reason="All messages contained blocked content", - description="This is a test of content blocking", - ), - ) - - # Return modified payload if messages were filtered - if len(filtered_messages) != len(payload.messages): - modified_payload = AgentPreInvokePayload( - agent_id=payload.agent_id, - messages=filtered_messages, - tools=payload.tools, - headers=payload.headers, - model=payload.model, - system_prompt=payload.system_prompt, - parameters=payload.parameters, - ) - return AgentPreInvokeResult(modified_payload=modified_payload) - - return AgentPreInvokeResult(continue_processing=True) - - async def agent_post_invoke(self, payload: AgentPostInvokePayload, context: PluginContext) -> AgentPostInvokeResult: - """Filter response messages containing blocked words. - - Args: - payload: The agent post-invoke payload. - context: Contextual information about the hook call. - - Returns: - The result with filtered messages or violation. - """ - blocked_words = self.config.config.get("blocked_words", []) - - # Filter messages - filtered_messages = [] - for msg in payload.messages: - if hasattr(msg.content, "text") and isinstance(msg.content, str): - text_lower = msg.content.text.lower() - if any(word in text_lower for word in blocked_words): - # Skip this message - continue - filtered_messages.append(msg) - - # If all messages were blocked, return violation - if not filtered_messages and payload.messages: - from mcpgateway.plugins.framework import PluginViolation - - return AgentPostInvokeResult( - continue_processing=False, - violation=PluginViolation( - code="BLOCKED_CONTENT", - reason="All response messages contained blocked content", - description="This is a test of content blocking", - ), - ) - - # Return modified payload if messages were filtered - if len(filtered_messages) != len(payload.messages): - modified_payload = AgentPostInvokePayload( - agent_id=payload.agent_id, messages=filtered_messages, tool_calls=payload.tool_calls - ) - return AgentPostInvokeResult(modified_payload=modified_payload) - - return AgentPostInvokeResult(continue_processing=True) - - -class ContextTrackingAgentPlugin(Plugin): - """An agent plugin that tracks state in local context.""" - - async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context: PluginContext) -> AgentPreInvokeResult: - """Track invocation count in local context. - - Args: - payload: The agent pre-invoke payload. - context: Contextual information about the hook call. - - Returns: - The result with updated local context. - """ - # Increment counter in local context - counter = context.metadata.get("invocation_count", 0) - context.metadata["invocation_count"] = counter + 1 - context.metadata["agent_id"] = payload.agent_id - - return AgentPreInvokeResult(continue_processing=True) - - async def agent_post_invoke(self, payload: AgentPostInvokePayload, context: PluginContext) -> AgentPostInvokeResult: - """Verify context persists from pre-invoke. - - Args: - payload: The agent post-invoke payload. - context: Contextual information about the hook call. - - Returns: - The result after verifying context. - """ - # Verify context persisted - counter = context.metadata.get("invocation_count", 0) - agent_id = context.metadata.get("agent_id", "") - - # Add metadata about the context - context.metadata["context_verified"] = counter > 0 and agent_id == payload.agent_id - - return AgentPostInvokeResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/context.py b/tests/unit/cpex/fixtures/plugins/context.py deleted file mode 100644 index e5f40a16..00000000 --- a/tests/unit/cpex/fixtures/plugins/context.py +++ /dev/null @@ -1,210 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Location: ./tests/unit/cpex/fixtures/plugins/context.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Context plugin. -""" - -from cpex.framework import ( - Plugin, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -class ContextPlugin(Plugin): - """A simple Context plugin.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - """ - context.state["key1"] = "value1" - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - if "key1" not in context.state or context.state["key1"] != "value1": - raise ValueError("key1 not in context!! It should be!!") - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - context.state["key2"] = "value2" - context.global_context.state["globkey1"] = "globvalue1" - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - if "key2" not in context.state or context.state["key2"] != "value2": - raise ValueError("key2 not in context!! It should be!!") - if "globkey1" not in context.global_context.state or context.global_context.state["globkey1"] != "globvalue1": - raise ValueError("globkey1 not in context!! It should be!!") - context.state["key3"] = "value3" - context.global_context.state["globkey2"] = "globvalue2" - return ToolPostInvokeResult(continue_processing=True) - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePostFetchResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePreFetchResult(continue_processing=True) - - -class ContextPlugin2(Plugin): - """A simple Context plugin.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - """ - if "key1" in context.state: - raise ValueError("key1 should not be in ContextPlugin2's context") - # context.state["cp2key1"] = "cp2value1" - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - if "key1" not in context.state or context.state["key1"] != "value1": - raise ValueError("key1 not in context!! It should be!!") - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - if "key2" in context.state: - raise ValueError("key2 should not be in ContextPlugin2's context") - context.state["cp2key1"] = "cp2value1" - if "globkey1" not in context.global_context.state: - raise ValueError("globkey1 should be in ContextPlugin2's context") - context.global_context.state["gcp2globkey1"] = "gcp2globvalue1" - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - if "key2" in context.state: - raise ValueError("key2 should not be in ContextPlugin2's context") - if "globkey1" not in context.global_context.state or context.global_context.state["globkey1"] != "globvalue1": - raise ValueError("globkey1 not in context!! It should be!!") - context.state["cp2key2"] = "cp2value2" - context.global_context.state["gcp2globkey2"] = "gcp2globvalue2" - return ToolPostInvokeResult(continue_processing=True) - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePostFetchResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePreFetchResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/cross_hook_context.py b/tests/unit/cpex/fixtures/plugins/cross_hook_context.py deleted file mode 100644 index 5ff8799b..00000000 --- a/tests/unit/cpex/fixtures/plugins/cross_hook_context.py +++ /dev/null @@ -1,244 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Location: ./tests/unit/cpex/fixtures/plugins/cross_hook_context.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Cross-hook context sharing test plugin. - -This plugin demonstrates sharing context across different hook types: -- HTTP_PRE_REQUEST stores data -- HTTP_AUTH_CHECK_PERMISSION reads and verifies data -- TOOL_PRE_INVOKE reads and adds more data -- RESOURCE_PRE_FETCH reads and adds more data -- PROMPT_PRE_FETCH reads and adds more data -""" - -import logging - -from cpex.framework import ( - HttpAuthCheckPermissionPayload, - HttpAuthCheckPermissionResult, - HttpPreRequestPayload, - HttpPreRequestResult, - Plugin, - PluginContext, - PromptPrehookPayload, - PromptPrehookResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - -logger = logging.getLogger("cross_hook_context_plugin") -logger.setLevel(logging.INFO) # Ensure INFO level logs are captured - - -class CrossHookContextPlugin(Plugin): - """Plugin that demonstrates cross-hook context sharing. - - This plugin stores context in HTTP_PRE_REQUEST and verifies it's accessible - in subsequent hooks like HTTP_AUTH_CHECK_PERMISSION, TOOL_PRE_INVOKE, - RESOURCE_PRE_FETCH, and PROMPT_PRE_FETCH. - """ - - async def http_pre_request(self, payload: HttpPreRequestPayload, context: PluginContext) -> HttpPreRequestResult: - """Store initial context data in HTTP_PRE_REQUEST hook. - - Args: - payload: The HTTP request payload. - context: Plugin context for state storage. - - Returns: - Result allowing processing to continue. - """ - logger.info( - f"🔍 [CrossHookContextPlugin] HTTP_PRE_REQUEST executed - " - f"request_id={context.global_context.request_id}, " - f"path={payload.path}, method={payload.method}" - ) - - # Store data in plugin-specific state - context.state["http_timestamp"] = "2025-01-01T00:00:00Z" - context.state["http_request_path"] = payload.path - context.state["http_method"] = payload.method - - # Also store in global context to show it's shared - context.global_context.state["shared_request_id"] = context.global_context.request_id - - return HttpPreRequestResult(continue_processing=True) - - async def http_auth_check_permission( - self, payload: HttpAuthCheckPermissionPayload, context: PluginContext - ) -> HttpAuthCheckPermissionResult: - """Verify context from HTTP_PRE_REQUEST is accessible. - - Args: - payload: The permission check payload. - context: Plugin context that should contain data from HTTP_PRE_REQUEST. - - Returns: - Result with permission decision. - - Raises: - ValueError: If expected context data is missing. - """ - logger.info( - f"🔍 [CrossHookContextPlugin] HTTP_AUTH_CHECK_PERMISSION executed - " - f"request_id={context.global_context.request_id}, " - f"user_email={payload.user_email}" - ) - - # Verify we can read data stored in HTTP_PRE_REQUEST - if "http_timestamp" not in context.state: - raise ValueError("http_timestamp not found in context! Cross-hook sharing failed.") - - if "http_request_path" not in context.state: - raise ValueError("http_request_path not found in context!") - - # Verify global context is shared - if "shared_request_id" not in context.global_context.state: - raise ValueError("shared_request_id not found in global context!") - - # Verify request_id consistency - shared_request_id = context.global_context.state["shared_request_id"] - if shared_request_id != context.global_context.request_id: - raise ValueError( - f"Request ID mismatch! shared_request_id={shared_request_id}, " - f"global_context.request_id={context.global_context.request_id}" - ) - - logger.info(f"✅ [CrossHookContextPlugin] Request ID verified: {context.global_context.request_id}") - - # Add permission-specific data - context.state["permission_checked"] = True - context.state["user_email"] = payload.user_email - - return HttpAuthCheckPermissionResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Verify context from HTTP hooks is accessible in tool hooks. - - Args: - payload: The tool invocation payload. - context: Plugin context that should contain data from HTTP hooks. - - Returns: - Result allowing tool invocation to continue. - - Raises: - ValueError: If expected context data is missing. - """ - logger.info( - f"🔍 [CrossHookContextPlugin] TOOL_PRE_INVOKE executed - " - f"request_id={context.global_context.request_id}, " - f"tool_name={payload.name}" - ) - - # Verify we can read data from HTTP_PRE_REQUEST - if "http_timestamp" not in context.state: - raise ValueError("http_timestamp not found in tool hook! Cross-hook sharing failed.") - - # Verify we can read data from HTTP_AUTH_CHECK_PERMISSION - if "permission_checked" not in context.state: - raise ValueError("permission_checked not found in tool hook!") - - # Verify request_id consistency - if "shared_request_id" in context.global_context.state: - shared_request_id = context.global_context.state["shared_request_id"] - if shared_request_id != context.global_context.request_id: - raise ValueError( - f"Request ID mismatch in tool hook! shared_request_id={shared_request_id}, " - f"global_context.request_id={context.global_context.request_id}" - ) - - # Add tool-specific data - context.state["tool_name"] = payload.name - context.state["tool_invoked_at"] = "2025-01-01T00:01:00Z" - - return ToolPreInvokeResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Verify context from HTTP hooks is accessible in resource hooks. - - Args: - payload: The resource fetch payload. - context: Plugin context that should contain data from HTTP hooks. - - Returns: - Result allowing resource fetch to continue. - - Raises: - ValueError: If expected context data is missing. - """ - logger.info( - f"🔍 [CrossHookContextPlugin] RESOURCE_PRE_FETCH executed - " - f"request_id={context.global_context.request_id}, " - f"resource_uri={payload.uri}" - ) - - # Verify we can read data from HTTP_PRE_REQUEST - if "http_timestamp" not in context.state: - raise ValueError("http_timestamp not found in resource hook! Cross-hook sharing failed.") - - # Verify global context is shared - if "shared_request_id" not in context.global_context.state: - raise ValueError("shared_request_id not found in resource hook!") - - # Verify request_id consistency - shared_request_id = context.global_context.state["shared_request_id"] - if shared_request_id != context.global_context.request_id: - raise ValueError( - f"Request ID mismatch in resource hook! shared_request_id={shared_request_id}, " - f"global_context.request_id={context.global_context.request_id}" - ) - - # Add resource-specific data - context.state["resource_uri"] = payload.uri - context.state["resource_fetched_at"] = "2025-01-01T00:02:00Z" - - return ResourcePreFetchResult(continue_processing=True) - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """Verify context from HTTP hooks is accessible in prompt hooks. - - Args: - payload: The prompt fetch payload. - context: Plugin context that should contain data from HTTP hooks. - - Returns: - Result allowing prompt fetch to continue. - - Raises: - ValueError: If expected context data is missing. - """ - logger.info( - f"🔍 [CrossHookContextPlugin] PROMPT_PRE_FETCH executed - " - f"request_id={context.global_context.request_id}, " - f"prompt_id={payload.prompt_id}" - ) - - # Verify we can read data from HTTP_PRE_REQUEST - if "http_timestamp" not in context.state: - raise ValueError("http_timestamp not found in prompt hook! Cross-hook sharing failed.") - - # Verify global context is shared - if "shared_request_id" not in context.global_context.state: - raise ValueError("shared_request_id not found in prompt hook!") - - # Verify request_id consistency - shared_request_id = context.global_context.state["shared_request_id"] - if shared_request_id != context.global_context.request_id: - raise ValueError( - f"Request ID mismatch in prompt hook! shared_request_id={shared_request_id}, " - f"global_context.request_id={context.global_context.request_id}" - ) - - # Add prompt-specific data - context.state["prompt_id"] = payload.prompt_id - context.state["prompt_fetched_at"] = "2025-01-01T00:03:00Z" - - return PromptPrehookResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/deny_filter.py b/tests/unit/cpex/fixtures/plugins/deny_filter.py deleted file mode 100644 index 7eb1275e..00000000 --- a/tests/unit/cpex/fixtures/plugins/deny_filter.py +++ /dev/null @@ -1,79 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/deny_filter.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Simple example plugin for searching and replacing text. -This module loads configurations for plugins. -""" - -# Third-Party -import logging - -from pydantic import BaseModel - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PluginViolation, - PromptPrehookPayload, - PromptPrehookResult, -) - -logger = logging.getLogger(__name__) - - -class DenyListConfig(BaseModel): - """Configuration for deny list plugin. - - Attributes: - words: List of words to deny. - """ - - words: list[str] - - -class DenyListPlugin(Plugin): - """Example deny list plugin.""" - - def __init__(self, config: PluginConfig): - """Initialize the deny list plugin. - - Args: - config: Plugin configuration. - """ - super().__init__(config) - self._dconfig = DenyListConfig.model_validate(self._config.config) - self._deny_list = [] - for word in self._dconfig.words: - self._deny_list.append(word) - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - if payload.args: - for key in payload.args: - if any(word in payload.args[key] for word in self._deny_list): - violation = PluginViolation( - reason="Prompt not allowed", - description="A deny word was found in the prompt", - code="deny", - details={}, - ) - logger.warning(f"Deny word detected in prompt argument '{key}'") - return PromptPrehookResult(modified_payload=payload, violation=violation, continue_processing=False) - return PromptPrehookResult(modified_payload=payload) - - async def shutdown(self) -> None: - """Cleanup when plugin shuts down.""" - logger.info("Deny list plugin shutting down") diff --git a/tests/unit/cpex/fixtures/plugins/error.py b/tests/unit/cpex/fixtures/plugins/error.py deleted file mode 100644 index 029f46fc..00000000 --- a/tests/unit/cpex/fixtures/plugins/error.py +++ /dev/null @@ -1,104 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Location: ./tests/unit/cpex/fixtures/plugins/error.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Error plugin. -""" - -from cpex.framework import ( - Plugin, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -class ErrorPlugin(Plugin): - """A simple error plugin.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - """ - raise ValueError("Sadly! Prompt prefetch is broken!") - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - raise ValueError("Sadly! Prompt postfetch is broken!") - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - raise ValueError("Sadly! Tool prefetch is broken!") - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - raise ValueError("Sadly! Tool postfetch is broken!") - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePostFetchResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePreFetchResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/extensions_aware.py b/tests/unit/cpex/fixtures/plugins/extensions_aware.py deleted file mode 100644 index 3d59ab03..00000000 --- a/tests/unit/cpex/fixtures/plugins/extensions_aware.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test plugin that accepts extensions as a third parameter. - -Used to test the accepts_extensions=True path through -_execute_with_timeout in the PluginManager. -""" - -from cpex.framework import Plugin -from cpex.framework.decorator import hook -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.hooks.tools import ToolPreInvokePayload -from cpex.framework.models import PluginContext, PluginResult - - -class ExtensionsAwarePlugin(Plugin): - """A plugin that receives and uses filtered extensions.""" - - @hook("tool_pre_invoke") - async def tool_pre_invoke( - self, - payload: ToolPreInvokePayload, - context: PluginContext, - extensions: Extensions, - ) -> PluginResult: - """Check extensions for required role. Denies if role missing.""" - if extensions and extensions.security and extensions.security.subject: - roles = extensions.security.subject.roles or frozenset() - if "required_role" in roles: - return PluginResult(continue_processing=True) - - # Allow if no extensions provided (backward compat) - if extensions is None: - return PluginResult(continue_processing=True) - - return PluginResult(continue_processing=True) - - -class ExtensionsLabelPlugin(Plugin): - """A plugin that reads labels from extensions and adds a label.""" - - @hook("tool_pre_invoke") - async def tool_pre_invoke( - self, - payload: ToolPreInvokePayload, - context: PluginContext, - extensions: Extensions, - ) -> PluginResult: - """If extensions have security, add 'PLUGIN_TOUCHED' label.""" - if extensions and extensions.security: - new_labels = extensions.security.labels | frozenset({"PLUGIN_TOUCHED"}) - new_security = extensions.security.model_copy(update={"labels": new_labels}) - modified_ext = extensions.model_copy(update={"security": new_security}) - return PluginResult( - continue_processing=True, - modified_extensions=modified_ext, - ) - return PluginResult(continue_processing=True) - - -class ExtensionsCustomPlugin(Plugin): - """A plugin that reads labels and writes to custom extensions.""" - - @hook("tool_pre_invoke") - async def tool_pre_invoke( - self, - payload: ToolPreInvokePayload, - context: PluginContext, - extensions: Extensions, - ) -> PluginResult: - """Read labels, write observation to custom extensions.""" - pii_detected = False - if extensions and extensions.security: - pii_detected = "PII" in extensions.security.labels - - custom = dict(extensions.custom) if extensions and extensions.custom else {} - custom["pii_detected"] = pii_detected - modified_ext = extensions.model_copy(update={"custom": custom}) - return PluginResult( - continue_processing=True, - modified_extensions=modified_ext, - ) diff --git a/tests/unit/cpex/fixtures/plugins/headers.py b/tests/unit/cpex/fixtures/plugins/headers.py deleted file mode 100644 index f460fb28..00000000 --- a/tests/unit/cpex/fixtures/plugins/headers.py +++ /dev/null @@ -1,227 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Location: ./tests/unit/cpex/fixtures/plugins/headers.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti, Fred Araujo - -Headers plugin. -""" - -import logging - -from cpex.framework import ( - HttpHeaderPayload, - Plugin, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) -from cpex.framework.constants import GATEWAY_METADATA, TOOL_METADATA - -logger = logging.getLogger("header_plugin") - - -class HeadersMetaDataPlugin(Plugin): - """A simple header plugin to read and modify headers.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - """ - raise ValueError("Sadly! Prompt prefetch is broken!") - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - raise ValueError("Sadly! Prompt postfetch is broken!") - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - assert TOOL_METADATA in context.global_context.metadata - tool_meta = context.global_context.metadata[TOOL_METADATA] - assert tool_meta.original_name == "test_tool" - assert tool_meta.url.host == "example.com" - assert tool_meta.integration_type == "REST" or tool_meta.integration_type == "MCP" - headers = payload.headers.model_dump() if payload.headers else {} - if tool_meta.integration_type == "REST": - assert payload.headers - assert "Content-Type" in payload.headers - assert payload.headers["Content-Type"] == "application/json" - elif tool_meta.integration_type == "MCP": - assert GATEWAY_METADATA in context.global_context.metadata - gateway_meta = context.global_context.metadata[GATEWAY_METADATA] - assert gateway_meta.name == "test_gateway" - assert gateway_meta.transport == "sse" - assert gateway_meta.url.host == "example.com" - - headers["User-Agent"] = "Mozilla/5.0" - headers["Connection"] = "keep-alive" - modified_payload = payload.model_copy(update={"headers": HttpHeaderPayload(headers)}) - - return ToolPreInvokeResult(continue_processing=True, modified_payload=modified_payload) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - assert TOOL_METADATA in context.global_context.metadata - tool_meta = context.global_context.metadata[TOOL_METADATA] - assert tool_meta.original_name == "test_tool" - assert tool_meta.url.host == "example.com" - assert tool_meta.integration_type == "REST" or tool_meta.integration_type == "MCP" - if tool_meta.integration_type == "MCP": - assert GATEWAY_METADATA in context.global_context.metadata - gateway_meta = context.global_context.metadata[GATEWAY_METADATA] - assert gateway_meta.name == "test_gateway" - assert gateway_meta.transport == "sse" - assert gateway_meta.url.host == "example.com" - return ToolPostInvokeResult(continue_processing=True) - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePostFetchResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePreFetchResult(continue_processing=True) - - -class HeadersPlugin(Plugin): - """A simple header plugin to read and modify headers.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - """ - raise ValueError("Sadly! Prompt prefetch is broken!") - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - raise ValueError("Sadly! Prompt postfetch is broken!") - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - headers = payload.headers.model_dump() if payload.headers else {} - if payload.headers: - assert "Content-Type" in payload.headers - assert payload.headers["Content-Type"] == "application/json" - headers["User-Agent"] = "Mozilla/5.0" - headers["Connection"] = "keep-alive" - modified_payload = payload.model_copy(update={"headers": HttpHeaderPayload(headers)}) - return ToolPreInvokeResult(continue_processing=True, modified_payload=modified_payload) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - raise ValueError("Sadly! Tool postfetch is broken!") - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePostFetchResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePreFetchResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py deleted file mode 100644 index c1f341cb..00000000 --- a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py +++ /dev/null @@ -1,144 +0,0 @@ -"""A filter plugin. - -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: habeck - -This module loads configurations for plugins. -""" - -import logging - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) -from cpex.framework.hooks.agents import ( - AgentPostInvokePayload, - AgentPostInvokeResult, - AgentPreInvokePayload, - AgentPreInvokeResult, -) -from cpex.framework.hooks.resources import ( - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, -) - -logger = logging.getLogger(__name__) - - -class TestPlugin(Plugin): - """A filter plugin.""" - - def __init__(self, config: PluginConfig): - """Entry init block for plugin. - - Args: - logger: logger that the skill can make use of - config: the skill configuration - """ - super().__init__(config) - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - logger.info("TestPlugin: prompt_pre_fetch") - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - logger.info("TestPlugin: prompt_post_fetch") - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - logger.info("TestPlugin: tool_pre_invoke") - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - logger.info("TestPlugin: tool_post_invoke") - return ToolPostInvokeResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource is fetched. - Args: - payload: The resource payload to be analyzed. - context: Contextual information about the hook call. - """ - logger.info("TestPlugin: resource_pre_fetch") - return ResourcePreFetchResult(continue_processing=True) - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource is fetched. - Args: - payload: The resource payload to be analyzed. - context: Contextual information about the hook call. - """ - logger.info("TestPlugin: resource_post_fetch") - return ResourcePostFetchResult(continue_processing=True) - - async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context: PluginContext) -> AgentPreInvokeResult: - """Plugin hook run before an agent is invoked. - Args: - payload: The agent payload to be analyzed. - context: Contextual information about the hook call. - """ - logger.info("TestPlugin: agent_pre_invoke") - return AgentPreInvokeResult(continue_processing=True) - - async def agent_post_invoke(self, payload: AgentPostInvokePayload, context: PluginContext) -> AgentPostInvokeResult: - """Plugin hook run after an agent is invoked. - Args: - payload: The agent payload to be analyzed. - context: Contextual information about the hook call. - """ - logger.info("TestPlugin: agent_post_invoke") - return AgentPostInvokeResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt deleted file mode 100644 index e83eec53..00000000 --- a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -cpex>=0.1.0.dev4 \ No newline at end of file diff --git a/tests/unit/cpex/fixtures/plugins/passthrough.py b/tests/unit/cpex/fixtures/plugins/passthrough.py deleted file mode 100644 index 6dcad449..00000000 --- a/tests/unit/cpex/fixtures/plugins/passthrough.py +++ /dev/null @@ -1,106 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/passthrough.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Passthrough plugin. -""" - -# First-Party -from cpex.framework import ( - Plugin, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -class PassThroughPlugin(Plugin): - """A simple pass through plugin.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - return PromptPosthookResult(continue_processing=True) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - return ToolPostInvokeResult(continue_processing=True) - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Plugin hook run after a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePostFetchResult(continue_processing=True) - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Plugin hook run before a resource was fetched. - - Args: - payload: The resource result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the resource result should proceed. - """ - return ResourcePreFetchResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/resource_filter.py b/tests/unit/cpex/fixtures/plugins/resource_filter.py deleted file mode 100644 index ad2170ff..00000000 --- a/tests/unit/cpex/fixtures/plugins/resource_filter.py +++ /dev/null @@ -1,260 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/resource_filter.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Resource Filter Plugin - Demonstrates resource hook functionality. -This plugin demonstrates how to use resource_pre_fetch and resource_post_fetch hooks -to filter and modify resource content. It can: -- Block resources based on URI patterns or protocols -- Limit resource content size -- Redact sensitive information from resource content -- Add metadata to resources -""" - -# Standard -import re -from typing import List, Pattern -from urllib.parse import urlparse - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PluginMode, - PluginViolation, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, - ToolPostInvokePayload, - ToolPostInvokeResult, -) - - -class ResourceFilterPlugin(Plugin): - """Plugin that filters and modifies resources. - - This plugin demonstrates the use of resource hooks to: - - Validate resource URIs before fetching - - Filter content after fetching - - Add metadata to resources - - Block certain protocols or domains - """ - - def __init__(self, config: PluginConfig) -> None: - """Initialize the resource filter plugin. - - Args: - config: Plugin configuration containing filter settings. - """ - super().__init__(config) - plugin_config = config.config if config.config else {} - self.max_content_size = plugin_config.get("max_content_size", 1048576) - self.allowed_protocols = plugin_config.get("allowed_protocols", ["file", "http", "https"]) - self.blocked_domains = plugin_config.get("blocked_domains", []) - # Precompile content filter patterns for performance - self.content_filters: List[tuple[Pattern[str], str]] = [] - for filter_rule in plugin_config.get("content_filters", []): - pattern = filter_rule.get("pattern") - replacement = filter_rule.get("replacement", "***") - if pattern: - try: - compiled_pattern = re.compile(pattern, re.IGNORECASE) - self.content_filters.append((compiled_pattern, replacement)) - except re.error: - # Skip invalid patterns - pass - - async def resource_pre_fetch( - self, payload: ResourcePreFetchPayload, context: PluginContext - ) -> ResourcePreFetchResult: - """Validate and potentially modify resource requests before fetching. - - Args: - payload: The resource pre-fetch payload containing URI and metadata. - context: Plugin execution context. - - Returns: - ResourcePreFetchResult indicating whether to continue and any modifications. - """ - # Parse the URI - try: - parsed = urlparse(payload.uri) - except Exception as e: - violation = PluginViolation( - reason="Invalid URI", - description=f"Could not parse resource URI: {e}", - code="INVALID_URI", - details={"uri": payload.uri, "error": str(e)}, - ) - return ResourcePreFetchResult(continue_processing=False, violation=violation) - - # Check if URI has a scheme - if not parsed.scheme: - violation = PluginViolation( - reason="Invalid URI format", - description="URI must have a valid scheme (protocol)", - code="INVALID_URI", - details={"uri": payload.uri}, - ) - # In audit mode, log but continue - if self.mode == PluginMode.AUDIT: - return ResourcePreFetchResult(continue_processing=True, violation=violation, modified_payload=payload) - return ResourcePreFetchResult(continue_processing=False, violation=violation) - - # Check protocol - if parsed.scheme not in self.allowed_protocols: - violation = PluginViolation( - reason="Protocol not allowed", - description=f"Protocol '{parsed.scheme}' is not in allowed list", - code="PROTOCOL_BLOCKED", - details={"uri": payload.uri, "protocol": parsed.scheme, "allowed": self.allowed_protocols}, - ) - # In audit mode, log but continue - if self.mode == PluginMode.AUDIT: - return ResourcePreFetchResult(continue_processing=True, violation=violation, modified_payload=payload) - return ResourcePreFetchResult(continue_processing=False, violation=violation) - - # Check domain blocking (case-insensitive) - if parsed.netloc: - # Convert both to lowercase for comparison - domain_lower = parsed.netloc.lower() - blocked_domains_lower = [d.lower() for d in self.blocked_domains] - if domain_lower in blocked_domains_lower or any( - domain_lower.endswith("." + d) for d in blocked_domains_lower - ): - violation = PluginViolation( - reason="Domain is blocked", - description=f"Domain '{parsed.netloc}' is in blocked list", - code="DOMAIN_BLOCKED", - details={"uri": payload.uri, "domain": parsed.netloc}, - ) - # In audit mode, log but continue - if self.mode == PluginMode.AUDIT: - return ResourcePreFetchResult( - continue_processing=True, violation=violation, modified_payload=payload - ) - return ResourcePreFetchResult(continue_processing=False, violation=violation) - - # Add metadata to track this plugin processed the request - modified_payload = ResourcePreFetchPayload( - uri=payload.uri, - metadata={ - **(payload.metadata or {}), - "validated": True, - "protocol": parsed.scheme, - "request_id": context.global_context.request_id, - "user": context.global_context.user, - "resource_filter_plugin": "pre_fetch_validated", - "allowed_size": self.max_content_size, - }, - ) - - # Store validation info in context for post-fetch - context.set_state("uri_validated", True) - context.set_state("original_uri", payload.uri) - - return ResourcePreFetchResult( - continue_processing=True, modified_payload=modified_payload, metadata={"validation": "passed"} - ) - - async def resource_post_fetch( - self, payload: ResourcePostFetchPayload, context: PluginContext - ) -> ResourcePostFetchResult: - """Filter and modify resource content after fetching. - - Args: - payload: The resource post-fetch payload containing fetched content. - context: Plugin execution context. - - Returns: - ResourcePostFetchResult with potentially modified content. - """ - # Check if pre-fetch validation was done - if not context.get_state("uri_validated"): - # This resource wasn't validated in pre-fetch, skip processing - return ResourcePostFetchResult(continue_processing=True, modified_payload=payload) - - # Process content if it's text - modified_content = payload.content - content_was_modified = False - - # Apply content filters if we have text content - if hasattr(payload.content, "text") and payload.content.text: - original_text = payload.content.text - filtered_text = original_text - - # Check content size - if len(filtered_text.encode("utf-8")) > self.max_content_size: - violation = PluginViolation( - reason="Content exceeds maximum size", - description=f"Resource content exceeds maximum size of {self.max_content_size} bytes", - code="CONTENT_TOO_LARGE", - details={ - "uri": payload.uri, - "size": len(filtered_text.encode("utf-8")), - "max_size": self.max_content_size, - }, - ) - # In audit mode, log but continue - if self.mode == PluginMode.AUDIT: - return ResourcePostFetchResult( - continue_processing=True, violation=violation, modified_payload=payload - ) - return ResourcePostFetchResult(continue_processing=False, violation=violation) - - # Apply content filters - for compiled_pattern, replacement in self.content_filters: - filtered_text = compiled_pattern.sub(replacement, filtered_text) - - # Update content if it was modified - if filtered_text != original_text: - # Create new content object with filtered text - # First-Party - from mcpgateway.common.models import ResourceContent - - modified_content = ResourceContent( - type=payload.content.type, - id=payload.content.id, - uri=payload.content.uri, - mime_type=getattr(payload.content, "mime_type", None), - text=filtered_text, - blob=getattr(payload.content, "blob", None), - ) - content_was_modified = True - context.set_state("content_filtered", True) - - # Only create modified payload if content was actually modified - if content_was_modified: - modified_payload = ResourcePostFetchPayload(uri=payload.uri, content=modified_content) - else: - # Return original payload if nothing was modified - modified_payload = payload - - return ResourcePostFetchResult( - continue_processing=True, - modified_payload=modified_payload, - metadata={ - "filtered": context.get_state("content_filtered", False), - "original_uri": context.get_state("original_uri"), - }, - ) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Handle tool invocation results. - - This plugin focuses on resource filtering, so tool invocations pass through unmodified. - - Args: - payload: The tool invocation result payload. - context: Plugin execution context. - - Returns: - ToolPostInvokeResult indicating to continue processing without modifications. - """ - # This plugin is focused on resource filtering, not tool invocations - # Simply pass through without modification - return ToolPostInvokeResult(continue_processing=True, modified_payload=payload) diff --git a/tests/unit/cpex/fixtures/plugins/search_replace.py b/tests/unit/cpex/fixtures/plugins/search_replace.py deleted file mode 100644 index f9c409b0..00000000 --- a/tests/unit/cpex/fixtures/plugins/search_replace.py +++ /dev/null @@ -1,156 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/search_replace.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Fred Araujo - -Simple example plugin for searching and replacing text. -This module loads configurations for plugins. -""" - -# Standard -import copy -import re - -# Third-Party -from pydantic import BaseModel - -# First-Party -from cpex.framework import ( - Plugin, - PluginConfig, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -class SearchReplace(BaseModel): - """Search and replace pattern configuration. - - Attributes: - search: Regular expression pattern to search for. - replace: Replacement text. - """ - - search: str - replace: str - - -class SearchReplaceConfig(BaseModel): - """Configuration for search and replace plugin. - - Attributes: - words: List of search and replace patterns to apply. - """ - - words: list[SearchReplace] - - -class SearchReplacePlugin(Plugin): - """Example search replace plugin.""" - - def __init__(self, config: PluginConfig): - """Initialize the search and replace plugin. - - Args: - config: Plugin configuration containing search/replace patterns. - """ - super().__init__(config) - self._srconfig = SearchReplaceConfig.model_validate(self._config.config) - # Precompile regex patterns at initialization - self.__patterns = [] - for word in self._srconfig.words: - try: - compiled_pattern = re.compile(word.search) - self.__patterns.append((compiled_pattern, word.replace)) - except re.error: - # Skip invalid regex patterns - pass - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """The plugin hook run before a prompt is retrieved and rendered. - - Args: - payload: The prompt payload to be analyzed. - context: contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - if payload.args: - modified_args = dict(payload.args) - for pattern, replacement in self.__patterns: - for key, value in modified_args.items(): - if isinstance(value, str): - modified_args[key] = pattern.sub(replacement, value) - payload = payload.model_copy(update={"args": modified_args}) - return PromptPrehookResult(modified_payload=payload) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Plugin hook run after a prompt is rendered. - - Args: - payload: The prompt payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the prompt can proceed. - """ - - if payload.result.messages: - modified_result = copy.deepcopy(payload.result) - for index, message in enumerate(modified_result.messages): - for pattern, replacement in self.__patterns: - modified_result.messages[index].content.text = pattern.sub(replacement, message.content.text) - payload = payload.model_copy(update={"result": modified_result}) - return PromptPosthookResult(modified_payload=payload) - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. - - Args: - payload: The tool payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool can proceed. - """ - if payload.args: - modified_args = dict(payload.args) - for pattern, replacement in self.__patterns: - for key, value in modified_args.items(): - if isinstance(value, str): - modified_args[key] = pattern.sub(replacement, value) - payload = payload.model_copy(update={"args": modified_args}) - return ToolPreInvokeResult(modified_payload=payload) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. - - Args: - payload: The tool result payload to be analyzed. - context: Contextual information about the hook call. - - Returns: - The result of the plugin's analysis, including whether the tool result should proceed. - """ - if payload.result and isinstance(payload.result, dict): - modified_result = dict(payload.result) - for pattern, replacement in self.__patterns: - for key, value in modified_result.items(): - if isinstance(value, str): - modified_result[key] = pattern.sub(replacement, value) - payload = payload.model_copy(update={"result": modified_result}) - elif payload.result and isinstance(payload.result, str): - result = payload.result - for pattern, replacement in self.__patterns: - result = pattern.sub(replacement, result) - payload = payload.model_copy(update={"result": result}) - return ToolPostInvokeResult(modified_payload=payload) diff --git a/tests/unit/cpex/fixtures/plugins/simple.py b/tests/unit/cpex/fixtures/plugins/simple.py deleted file mode 100644 index 0d89a0c4..00000000 --- a/tests/unit/cpex/fixtures/plugins/simple.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/fixtures/plugins/simple.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Test Suite - -Simple minimal plugins for testing the plugin framework. -These plugins provide basic passthrough implementations for testing -registration, priority sorting, hook filtering, etc. -""" - -# First-Party -from cpex.framework import ( - Plugin, - PluginContext, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) - - -class SimplePromptPlugin(Plugin): - """Minimal plugin with prompt hooks for testing.""" - - async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: - """Passthrough prompt pre-fetch hook.""" - return PromptPrehookResult(continue_processing=True) - - async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: - """Passthrough prompt post-fetch hook.""" - return PromptPosthookResult(continue_processing=True) - - -class SimpleToolPlugin(Plugin): - """Minimal plugin with tool hooks for testing.""" - - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Passthrough tool pre-invoke hook.""" - return ToolPreInvokeResult(continue_processing=True) - - async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Passthrough tool post-invoke hook.""" - return ToolPostInvokeResult(continue_processing=True) diff --git a/tests/unit/cpex/framework/__init__.py b/tests/unit/cpex/framework/__init__.py deleted file mode 100644 index 0438bf6a..00000000 --- a/tests/unit/cpex/framework/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor -""" diff --git a/tests/unit/cpex/framework/cmf/__init__.py b/tests/unit/cpex/framework/cmf/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/cpex/framework/cmf/test_message.py b/tests/unit/cpex/framework/cmf/test_message.py deleted file mode 100644 index 955e3dec..00000000 --- a/tests/unit/cpex/framework/cmf/test_message.py +++ /dev/null @@ -1,769 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/cmf/test_message.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for CMF message models. -""" - -# Standard - -# Third-Party -import pytest - -# First-Party -from cpex.framework.cmf.message import ( - AudioContentPart, - AudioSource, - Channel, - ContentPart, - ContentType, - DocumentContentPart, - DocumentSource, - ImageContentPart, - ImageSource, - Message, - PromptRequest, - PromptRequestContentPart, - PromptResult, - PromptResultContentPart, - Resource, - ResourceContentPart, - ResourceRefContentPart, - ResourceReference, - ResourceType, - Role, - TextContent, - ThinkingContent, - ToolCall, - ToolCallContentPart, - ToolResult, - ToolResultContentPart, - VideoContentPart, - VideoSource, -) - -# --------------------------------------------------------------------------- -# Enum Tests -# --------------------------------------------------------------------------- - - -class TestRole: - """Tests for the Role enum.""" - - def test_values(self): - assert Role.SYSTEM.value == "system" - assert Role.DEVELOPER.value == "developer" - assert Role.USER.value == "user" - assert Role.ASSISTANT.value == "assistant" - assert Role.TOOL.value == "tool" - - def test_from_string(self): - assert Role("user") == Role.USER - assert Role("assistant") == Role.ASSISTANT - - def test_invalid_value(self): - with pytest.raises(ValueError): - Role("invalid") - - def test_member_count(self): - assert len(Role) == 5 - - -class TestChannel: - """Tests for the Channel enum.""" - - def test_values(self): - assert Channel.ANALYSIS.value == "analysis" - assert Channel.COMMENTARY.value == "commentary" - assert Channel.FINAL.value == "final" - - def test_from_string(self): - assert Channel("final") == Channel.FINAL - - def test_member_count(self): - assert len(Channel) == 3 - - -class TestContentType: - """Tests for the ContentType enum.""" - - def test_all_types_present(self): - expected = { - "text", - "thinking", - "tool_call", - "tool_result", - "resource", - "resource_ref", - "prompt_request", - "prompt_result", - "image", - "video", - "audio", - "document", - } - assert {ct.value for ct in ContentType} == expected - - def test_member_count(self): - assert len(ContentType) == 12 - - -class TestResourceType: - """Tests for the ResourceType enum.""" - - def test_all_types_present(self): - expected = {"file", "blob", "uri", "database", "api", "memory", "artifact"} - assert {rt.value for rt in ResourceType} == expected - - def test_member_count(self): - assert len(ResourceType) == 7 - - -# --------------------------------------------------------------------------- -# ContentPart Base Class Tests -# --------------------------------------------------------------------------- - - -class TestContentPart: - """Tests for the ContentPart base class.""" - - def test_subclass_relationship(self): - part = TextContent(text="hello") - assert isinstance(part, ContentPart) - - def test_wrapper_subclass_relationship(self): - part = ToolCallContentPart( - content=ToolCall(tool_call_id="tc1", name="test"), - ) - assert isinstance(part, ContentPart) - - def test_frozen(self): - part = TextContent(text="hello") - with pytest.raises(Exception): - part.text = "world" - - -# --------------------------------------------------------------------------- -# Domain Object Tests -# --------------------------------------------------------------------------- - - -class TestToolCallDomain: - """Tests for the ToolCall domain object.""" - - def test_creation(self): - call = ToolCall( - tool_call_id="tc_001", - name="get_user", - arguments={"user_id": "123"}, - ) - assert call.tool_call_id == "tc_001" - assert call.name == "get_user" - assert call.arguments == {"user_id": "123"} - - def test_default_arguments(self): - call = ToolCall(tool_call_id="tc_002", name="list_users") - assert call.arguments == {} - - def test_default_namespace(self): - call = ToolCall(tool_call_id="tc_003", name="test") - assert call.namespace is None - - def test_with_namespace(self): - call = ToolCall( - tool_call_id="tc_004", - name="get_user", - namespace="user-service", - ) - assert call.namespace == "user-service" - - def test_frozen(self): - call = ToolCall(tool_call_id="tc_005", name="test") - with pytest.raises(Exception): - call.name = "other" - - -class TestToolResultDomain: - """Tests for the ToolResult domain object.""" - - def test_creation(self): - result = ToolResult( - tool_call_id="tc_001", - tool_name="get_user", - content={"name": "Alice"}, - ) - assert result.tool_call_id == "tc_001" - assert result.tool_name == "get_user" - assert result.content == {"name": "Alice"} - assert result.is_error is False - - def test_error_result(self): - result = ToolResult( - tool_call_id="tc_002", - tool_name="fail_tool", - content="Something went wrong", - is_error=True, - ) - assert result.is_error is True - - def test_default_content(self): - result = ToolResult(tool_call_id="tc_003", tool_name="test") - assert result.content is None - assert result.is_error is False - - -class TestImageSourceDomain: - """Tests for the ImageSource domain object.""" - - def test_url_image(self): - img = ImageSource(type="url", data="https://example.com/photo.jpg") - assert img.type == "url" - assert img.media_type is None - - def test_base64_image(self): - img = ImageSource( - type="base64", - data="iVBORw0KGgo...", - media_type="image/png", - ) - assert img.type == "base64" - assert img.media_type == "image/png" - - -class TestVideoSourceDomain: - """Tests for the VideoSource domain object.""" - - def test_creation(self): - vid = VideoSource(type="url", data="https://example.com/clip.mp4") - assert vid.duration_ms is None - - def test_with_duration(self): - vid = VideoSource( - type="url", - data="https://example.com/clip.mp4", - duration_ms=30000, - ) - assert vid.duration_ms == 30000 - - -class TestAudioSourceDomain: - """Tests for the AudioSource domain object.""" - - def test_creation(self): - aud = AudioSource(type="url", data="https://example.com/track.mp3") - assert aud.type == "url" - - -class TestDocumentSourceDomain: - """Tests for the DocumentSource domain object.""" - - def test_creation(self): - doc = DocumentSource( - type="base64", - data="JVBERi0xLjQ...", - media_type="application/pdf", - title="Annual Report", - ) - assert doc.title == "Annual Report" - - -class TestResourceDomain: - """Tests for the Resource domain object.""" - - def test_creation(self): - res = Resource( - resource_request_id="rr_001", - uri="file:///data/report.csv", - name="Q4 Report", - resource_type=ResourceType.FILE, - content="col1,col2\n1,2", - mime_type="text/csv", - ) - assert res.uri == "file:///data/report.csv" - assert res.resource_type == ResourceType.FILE - - def test_minimal_creation(self): - res = Resource( - resource_request_id="rr_002", - uri="db://users/42", - resource_type=ResourceType.DATABASE, - ) - assert res.name is None - assert res.content is None - assert res.blob is None - - def test_blob_resource(self): - res = Resource( - resource_request_id="rr_003", - uri="blob://data", - resource_type=ResourceType.BLOB, - blob=b"\x00\x01\x02", - ) - assert res.blob == b"\x00\x01\x02" - - -class TestResourceReferenceDomain: - """Tests for the ResourceReference domain object.""" - - def test_creation(self): - ref = ResourceReference( - resource_request_id="rr_004", - uri="file:///path/to/file.txt", - resource_type=ResourceType.FILE, - ) - assert ref.uri == "file:///path/to/file.txt" - - def test_with_range(self): - ref = ResourceReference( - resource_request_id="rr_005", - uri="file:///code.py", - resource_type=ResourceType.FILE, - range_start=10, - range_end=50, - ) - assert ref.range_start == 10 - assert ref.range_end == 50 - - def test_with_selector(self): - ref = ResourceReference( - resource_request_id="rr_006", - uri="api://data", - resource_type=ResourceType.API, - selector="$.results[0]", - ) - assert ref.selector == "$.results[0]" - - -class TestPromptRequestDomain: - """Tests for the PromptRequest domain object.""" - - def test_creation(self): - req = PromptRequest( - prompt_request_id="pr_001", - name="summarize", - arguments={"text": "Long document..."}, - ) - assert req.name == "summarize" - assert req.arguments == {"text": "Long document..."} - - def test_defaults(self): - req = PromptRequest(prompt_request_id="pr_002", name="test") - assert req.arguments == {} - assert req.server_id is None - - -class TestPromptResultDomain: - """Tests for the PromptResult domain object.""" - - def test_creation(self): - result = PromptResult( - prompt_request_id="pr_001", - prompt_name="summarize", - content="This document discusses...", - ) - assert result.prompt_name == "summarize" - assert result.is_error is False - - def test_error_result(self): - result = PromptResult( - prompt_request_id="pr_002", - prompt_name="fail_prompt", - is_error=True, - error_message="Template not found", - ) - assert result.is_error is True - assert result.error_message == "Template not found" - - def test_defaults(self): - result = PromptResult(prompt_request_id="pr_003", prompt_name="test") - assert result.messages == [] - assert result.content is None - - -# --------------------------------------------------------------------------- -# ContentPart Wrapper Tests -# --------------------------------------------------------------------------- - - -class TestTextContent: - """Tests for TextContent.""" - - def test_creation(self): - part = TextContent(text="Hello, world!") - assert part.content_type == ContentType.TEXT - assert part.text == "Hello, world!" - - def test_frozen(self): - part = TextContent(text="original") - with pytest.raises(Exception): - part.text = "modified" - - def test_model_copy(self): - part = TextContent(text="original") - modified = part.model_copy(update={"text": "updated"}) - assert part.text == "original" - assert modified.text == "updated" - - -class TestThinkingContent: - """Tests for ThinkingContent.""" - - def test_creation(self): - part = ThinkingContent(text="Let me analyze this...") - assert part.content_type == ContentType.THINKING - assert part.text == "Let me analyze this..." - - -class TestToolCallContentPart: - """Tests for ToolCallContentPart wrapper.""" - - def test_creation(self): - call = ToolCall(tool_call_id="tc_001", name="get_user", arguments={"user_id": "123"}) - part = ToolCallContentPart(content=call) - assert part.content_type == ContentType.TOOL_CALL - assert part.content.name == "get_user" - assert part.content.arguments == {"user_id": "123"} - - def test_frozen(self): - part = ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="test")) - with pytest.raises(Exception): - part.content = ToolCall(tool_call_id="tc2", name="other") - - -class TestToolResultContentPart: - """Tests for ToolResultContentPart wrapper.""" - - def test_creation(self): - result = ToolResult(tool_call_id="tc_001", tool_name="get_user", content={"name": "Alice"}) - part = ToolResultContentPart(content=result) - assert part.content_type == ContentType.TOOL_RESULT - assert part.content.tool_name == "get_user" - assert part.content.is_error is False - - -class TestResourceContentPart: - """Tests for ResourceContentPart wrapper.""" - - def test_creation(self): - res = Resource( - resource_request_id="rr_001", - uri="file:///data/report.csv", - resource_type=ResourceType.FILE, - ) - part = ResourceContentPart(content=res) - assert part.content_type == ContentType.RESOURCE - assert part.content.uri == "file:///data/report.csv" - - -class TestImageContentPart: - """Tests for ImageContentPart wrapper.""" - - def test_creation(self): - img = ImageSource(type="url", data="https://example.com/photo.jpg") - part = ImageContentPart(content=img) - assert part.content_type == ContentType.IMAGE - assert part.content.type == "url" - - -class TestDocumentContentPart: - """Tests for DocumentContentPart wrapper.""" - - def test_creation(self): - doc = DocumentSource( - type="base64", - data="JVBERi0xLjQ...", - media_type="application/pdf", - title="Annual Report", - ) - part = DocumentContentPart(content=doc) - assert part.content_type == ContentType.DOCUMENT - assert part.content.title == "Annual Report" - - -# --------------------------------------------------------------------------- -# Message Tests -# --------------------------------------------------------------------------- - - -class TestMessage: - """Tests for the Message model.""" - - def test_simple_message(self): - msg = Message( - role=Role.USER, - content=[TextContent(text="Hello")], - ) - assert msg.role == Role.USER - assert msg.schema_version == "2.0" - assert msg.channel is None - assert len(msg.content) == 1 - - def test_empty_content(self): - msg = Message(role=Role.SYSTEM) - assert msg.content == [] - - def test_multi_part_message(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ThinkingContent(text="Reasoning..."), - TextContent(text="Here is the answer."), - ToolCallContentPart( - content=ToolCall(tool_call_id="tc_001", name="search", arguments={"q": "test"}), - ), - ], - ) - assert len(msg.content) == 3 - assert msg.content[0].content_type == ContentType.THINKING - assert msg.content[1].content_type == ContentType.TEXT - assert msg.content[2].content_type == ContentType.TOOL_CALL - - def test_with_channel(self): - msg = Message( - role=Role.ASSISTANT, - content=[TextContent(text="Final answer.")], - channel=Channel.FINAL, - ) - assert msg.channel == Channel.FINAL - - def test_frozen(self): - msg = Message(role=Role.USER, content=[TextContent(text="Hi")]) - with pytest.raises(Exception): - msg.role = Role.ASSISTANT - - def test_model_copy(self): - msg = Message(role=Role.USER, content=[TextContent(text="Hi")]) - updated = msg.model_copy(update={"channel": Channel.FINAL}) - assert msg.channel is None - assert updated.channel == Channel.FINAL - assert updated.role == Role.USER - - def test_deserialization_from_dict(self): - msg = Message.model_validate( - { - "role": "user", - "content": [ - {"content_type": "text", "text": "Hello"}, - {"content_type": "tool_call", "content": {"tool_call_id": "tc1", "name": "foo", "arguments": {}}}, - ], - } - ) - assert msg.role == Role.USER - assert len(msg.content) == 2 - assert isinstance(msg.content[0], TextContent) - assert isinstance(msg.content[1], ToolCallContentPart) - - def test_deserialization_all_content_types(self): - msg = Message.model_validate( - { - "role": "assistant", - "content": [ - {"content_type": "text", "text": "hi"}, - {"content_type": "thinking", "text": "hmm"}, - {"content_type": "tool_call", "content": {"tool_call_id": "t1", "name": "x", "arguments": {}}}, - {"content_type": "tool_result", "content": {"tool_call_id": "t1", "tool_name": "x"}}, - { - "content_type": "resource", - "content": {"resource_request_id": "r1", "uri": "file:///a", "resource_type": "file"}, - }, - { - "content_type": "resource_ref", - "content": {"resource_request_id": "r2", "uri": "db://b", "resource_type": "database"}, - }, - {"content_type": "prompt_request", "content": {"prompt_request_id": "p1", "name": "s"}}, - {"content_type": "prompt_result", "content": {"prompt_request_id": "p1", "prompt_name": "s"}}, - {"content_type": "image", "content": {"type": "url", "data": "http://img"}}, - {"content_type": "video", "content": {"type": "url", "data": "http://vid"}}, - {"content_type": "audio", "content": {"type": "url", "data": "http://aud"}}, - {"content_type": "document", "content": {"type": "url", "data": "http://doc"}}, - ], - } - ) - assert len(msg.content) == 12 - expected_types = [ - TextContent, - ThinkingContent, - ToolCallContentPart, - ToolResultContentPart, - ResourceContentPart, - ResourceRefContentPart, - PromptRequestContentPart, - PromptResultContentPart, - ImageContentPart, - VideoContentPart, - AudioContentPart, - DocumentContentPart, - ] - for part, expected in zip(msg.content, expected_types): - assert isinstance(part, expected), f"Expected {expected.__name__}, got {type(part).__name__}" - - def test_serialization_roundtrip(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - TextContent(text="hello"), - ToolCallContentPart( - content=ToolCall(tool_call_id="tc1", name="test", arguments={"a": 1}), - ), - ], - ) - data = msg.model_dump() - restored = Message.model_validate(data) - assert restored.role == msg.role - assert len(restored.content) == 2 - assert restored.content[0].text == "hello" - assert restored.content[1].content.name == "test" - - def test_iter_views(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - TextContent(text="hello"), - ToolCallContentPart( - content=ToolCall(tool_call_id="tc1", name="test", arguments={}), - ), - ], - ) - views = list(msg.iter_views()) - assert len(views) == 2 - - -# --------------------------------------------------------------------------- -# Validation Tests -# --------------------------------------------------------------------------- - - -class TestResourceValidation: - """Tests for Resource model validators.""" - - def test_content_only(self): - res = Resource( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - content="hello", - ) - assert res.content == "hello" - assert res.blob is None - - def test_blob_only(self): - res = Resource( - resource_request_id="r1", - uri="file:///a.bin", - resource_type=ResourceType.FILE, - blob=b"\x00\x01", - ) - assert res.blob == b"\x00\x01" - assert res.content is None - - def test_neither_content_nor_blob(self): - res = Resource( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - ) - assert res.content is None - assert res.blob is None - - def test_content_and_blob_raises(self): - with pytest.raises(ValueError, match="cannot have both"): - Resource( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - content="hello", - blob=b"\x00", - ) - - -class TestResourceReferenceValidation: - """Tests for ResourceReference range validators.""" - - def test_valid_range(self): - ref = ResourceReference( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - range_start=10, - range_end=20, - ) - assert ref.range_start == 10 - assert ref.range_end == 20 - - def test_equal_range(self): - ref = ResourceReference( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - range_start=5, - range_end=5, - ) - assert ref.range_start == ref.range_end - - def test_invalid_range_raises(self): - with pytest.raises(ValueError, match="range_end.*must be >= range_start"): - ResourceReference( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - range_start=20, - range_end=10, - ) - - def test_start_only(self): - ref = ResourceReference( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - range_start=5, - ) - assert ref.range_start == 5 - assert ref.range_end is None - - def test_end_only(self): - ref = ResourceReference( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - range_end=10, - ) - assert ref.range_start is None - assert ref.range_end == 10 - - -class TestDiscriminator: - """Tests for content_type discriminator function.""" - - def test_missing_content_type_in_dict_raises(self): - with pytest.raises(Exception): - Message(role=Role.USER, content=[{"text": "hello"}]) - - def test_invalid_content_type_in_dict_raises(self): - with pytest.raises(Exception): - Message(role=Role.USER, content=[{"content_type": "bogus", "text": "hello"}]) - - -class TestMediaSourceLiteral: - """Tests that media source type fields enforce Literal['url', 'base64'].""" - - def test_image_source_valid_types(self): - assert ImageSource(type="url", data="https://x.com/a.jpg").type == "url" - assert ImageSource(type="base64", data="abc").type == "base64" - - def test_image_source_invalid_type(self): - with pytest.raises(Exception): - ImageSource(type="ftp", data="abc") - - def test_video_source_invalid_type(self): - with pytest.raises(Exception): - VideoSource(type="file", data="abc") - - def test_audio_source_invalid_type(self): - with pytest.raises(Exception): - AudioSource(type="stream", data="abc") - - def test_document_source_invalid_type(self): - with pytest.raises(Exception): - DocumentSource(type="unknown", data="abc") diff --git a/tests/unit/cpex/framework/cmf/test_view.py b/tests/unit/cpex/framework/cmf/test_view.py deleted file mode 100644 index 330cb669..00000000 --- a/tests/unit/cpex/framework/cmf/test_view.py +++ /dev/null @@ -1,1357 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/cmf/test_view.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for MessageView. -""" - -# Standard - -# Third-Party -import pytest - -# First-Party -from cpex.framework.cmf.message import ( - AudioContentPart, - AudioSource, - DocumentContentPart, - DocumentSource, - ImageContentPart, - ImageSource, - Message, - PromptRequest, - PromptRequestContentPart, - PromptResult, - PromptResultContentPart, - Resource, - ResourceContentPart, - ResourceRefContentPart, - ResourceReference, - ResourceType, - Role, - TextContent, - ThinkingContent, - ToolCall, - ToolCallContentPart, - ToolResult, - ToolResultContentPart, - VideoContentPart, - VideoSource, -) -from cpex.framework.cmf.view import ( - ViewAction, - ViewKind, - iter_views, -) -from cpex.framework.extensions.agent import AgentExtension -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.http import HttpExtension -from cpex.framework.extensions.request import RequestExtension -from cpex.framework.extensions.security import ( - DataPolicy, - ObjectSecurityProfile, - RetentionPolicy, - SecurityExtension, - SubjectExtension, - SubjectType, -) - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def simple_assistant_msg(): - """An assistant message with text, thinking, and a tool call.""" - return Message( - role=Role.ASSISTANT, - content=[ - ThinkingContent(text="User wants admin users."), - TextContent(text="Let me look that up."), - ToolCallContentPart( - content=ToolCall( - tool_call_id="tc_001", - name="execute_sql", - arguments={"query": "SELECT * FROM users WHERE role='admin'"}, - ), - ), - ], - ) - - -@pytest.fixture -def full_msg(): - """A message and extensions (separated per CMF design).""" - msg = Message( - role=Role.ASSISTANT, - content=[ - ToolCallContentPart( - content=ToolCall( - tool_call_id="tc_001", - name="get_compensation", - namespace="hr-server", - arguments={"employee_id": "emp-42"}, - ), - ), - ], - ) - ext = Extensions( - request=RequestExtension( - environment="production", - request_id="req-001", - ), - agent=AgentExtension( - input="Show me Alice's compensation", - session_id="sess-001", - conversation_id="conv-001", - turn=2, - agent_id="main-agent", - parent_agent_id="orchestrator", - ), - http=HttpExtension( - headers={ - "Authorization": "Bearer secret-token", - "Cookie": "session=abc", - "X-Request-ID": "req-001", - "Content-Type": "application/json", - }, - ), - security=SecurityExtension( - labels=frozenset({"CONFIDENTIAL"}), - classification="confidential", - subject=SubjectExtension( - id="user-alice", - type=SubjectType.USER, - roles=frozenset({"admin", "hr-manager"}), - permissions=frozenset({"read:compensation", "tools.execute"}), - teams=frozenset({"hr-team"}), - ), - objects={ - "get_compensation": ObjectSecurityProfile( - managed_by="tool", - permissions=["read:compensation"], - trust_domain="internal", - data_scope=["salary", "bonus"], - ), - }, - data={ - "get_compensation": DataPolicy( - apply_labels=["PII", "financial"], - denied_actions=["export", "forward", "log_raw"], - retention=RetentionPolicy( - policy="session", - max_age_seconds=3600, - ), - ), - }, - ), - ) - return msg, ext - - -# --------------------------------------------------------------------------- -# Enum Tests -# --------------------------------------------------------------------------- - - -class TestViewKind: - """Tests for ViewKind enum.""" - - def test_member_count(self): - assert len(ViewKind) == 12 - - def test_values_match_content_type(self): - from cpex.framework.cmf.message import ContentType - - for ct in ContentType: - assert ct.value in [vk.value for vk in ViewKind] - - -class TestViewAction: - """Tests for ViewAction enum.""" - - def test_member_count(self): - assert len(ViewAction) == 7 - - def test_values(self): - expected = {"read", "write", "execute", "invoke", "send", "receive", "generate"} - assert {va.value for va in ViewAction} == expected - - -# --------------------------------------------------------------------------- -# View Iteration -# --------------------------------------------------------------------------- - - -class TestIterViews: - """Tests for iter_views() and Message.iter_views().""" - - def test_standalone_and_method_match(self, simple_assistant_msg): - standalone = list(iter_views(simple_assistant_msg)) - method = list(simple_assistant_msg.iter_views()) - assert len(standalone) == len(method) == 3 - - def test_view_count(self, simple_assistant_msg): - views = list(iter_views(simple_assistant_msg)) - assert len(views) == 3 - - def test_view_kinds(self, simple_assistant_msg): - views = list(iter_views(simple_assistant_msg)) - assert views[0].kind == ViewKind.THINKING - assert views[1].kind == ViewKind.TEXT - assert views[2].kind == ViewKind.TOOL_CALL - - def test_empty_message(self): - msg = Message(role=Role.USER) - views = list(iter_views(msg)) - assert len(views) == 0 - - def test_single_content_part(self): - msg = Message(role=Role.USER, content=[TextContent(text="Hi")]) - views = list(iter_views(msg)) - assert len(views) == 1 - assert views[0].kind == ViewKind.TEXT - - def test_all_content_types(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - TextContent(text="hi"), - ThinkingContent(text="hmm"), - ToolCallContentPart(content=ToolCall(tool_call_id="t1", name="x", arguments={})), - ToolResultContentPart(content=ToolResult(tool_call_id="t1", tool_name="x", content="ok")), - ResourceContentPart( - content=Resource( - resource_request_id="r1", uri="file:///a", resource_type=ResourceType.FILE, content="data" - ) - ), - ResourceRefContentPart( - content=ResourceReference( - resource_request_id="r2", uri="db://b", resource_type=ResourceType.DATABASE - ) - ), - PromptRequestContentPart(content=PromptRequest(prompt_request_id="p1", name="summarize")), - PromptResultContentPart( - content=PromptResult(prompt_request_id="p1", prompt_name="summarize", content="summary") - ), - ImageContentPart(content=ImageSource(type="url", data="http://img")), - VideoContentPart(content=VideoSource(type="url", data="http://vid")), - AudioContentPart(content=AudioSource(type="url", data="http://aud")), - DocumentContentPart(content=DocumentSource(type="url", data="http://doc")), - ], - ) - views = list(iter_views(msg)) - assert len(views) == 12 - expected_kinds = [ - ViewKind.TEXT, - ViewKind.THINKING, - ViewKind.TOOL_CALL, - ViewKind.TOOL_RESULT, - ViewKind.RESOURCE, - ViewKind.RESOURCE_REF, - ViewKind.PROMPT_REQUEST, - ViewKind.PROMPT_RESULT, - ViewKind.IMAGE, - ViewKind.VIDEO, - ViewKind.AUDIO, - ViewKind.DOCUMENT, - ] - for view, expected in zip(views, expected_kinds): - assert view.kind == expected - - -# --------------------------------------------------------------------------- -# Core Properties -# --------------------------------------------------------------------------- - - -class TestCoreProperties: - """Tests for MessageView core properties.""" - - def test_role(self, simple_assistant_msg): - view = list(iter_views(simple_assistant_msg))[0] - assert view.role == Role.ASSISTANT - - def test_raw_access(self, simple_assistant_msg): - views = list(iter_views(simple_assistant_msg)) - assert isinstance(views[0].raw, ThinkingContent) - assert isinstance(views[2].raw, ToolCallContentPart) - - def test_content_text(self): - msg = Message(role=Role.USER, content=[TextContent(text="Hello")]) - view = list(iter_views(msg))[0] - assert view.content == "Hello" - - def test_content_thinking(self): - msg = Message(role=Role.ASSISTANT, content=[ThinkingContent(text="Reasoning...")]) - view = list(iter_views(msg))[0] - assert view.content == "Reasoning..." - - def test_content_tool_call(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="test", arguments={"key": "value"})) - ], - ) - view = list(iter_views(msg))[0] - assert view.content == '{"key": "value"}' - - def test_content_tool_result(self): - msg = Message( - role=Role.TOOL, - content=[ - ToolResultContentPart(content=ToolResult(tool_call_id="tc1", tool_name="test", content={"result": 42})) - ], - ) - view = list(iter_views(msg))[0] - assert view.content == '{"result": 42}' - - def test_content_tool_result_string(self): - msg = Message( - role=Role.TOOL, - content=[ - ToolResultContentPart(content=ToolResult(tool_call_id="tc1", tool_name="test", content="plain text")) - ], - ) - view = list(iter_views(msg))[0] - assert view.content == "plain text" - - def test_content_tool_result_none(self): - msg = Message( - role=Role.TOOL, - content=[ToolResultContentPart(content=ToolResult(tool_call_id="tc1", tool_name="test"))], - ) - view = list(iter_views(msg))[0] - assert view.content is None - - def test_content_resource(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource( - resource_request_id="r1", - uri="file:///a", - resource_type=ResourceType.FILE, - content="file data", - ) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.content == "file data" - - def test_content_prompt_request(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - PromptRequestContentPart( - content=PromptRequest(prompt_request_id="p1", name="s", arguments={"text": "hi"}) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.content == '{"text": "hi"}' - - def test_content_prompt_result(self): - msg = Message( - role=Role.TOOL, - content=[ - PromptResultContentPart( - content=PromptResult(prompt_request_id="p1", prompt_name="s", content="rendered") - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.content == "rendered" - - def test_content_media_none(self): - msg = Message( - role=Role.USER, - content=[ImageContentPart(content=ImageSource(type="url", data="http://img"))], - ) - view = list(iter_views(msg))[0] - assert view.content is None - - -# --------------------------------------------------------------------------- -# URI -# --------------------------------------------------------------------------- - - -class TestURI: - """Tests for synthetic URI generation.""" - - def test_tool_call_uri(self): - msg = Message( - role=Role.ASSISTANT, - content=[ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="get_user", arguments={}))], - ) - view = list(iter_views(msg))[0] - assert view.uri == "tool://_/get_user" - - def test_tool_call_uri_with_namespace(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ToolCallContentPart( - content=ToolCall(tool_call_id="tc1", name="get_user", namespace="user-svc", arguments={}) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.uri == "tool://user-svc/get_user" - - def test_tool_result_uri(self): - msg = Message( - role=Role.TOOL, - content=[ToolResultContentPart(content=ToolResult(tool_call_id="tc1", tool_name="get_user"))], - ) - view = list(iter_views(msg))[0] - assert view.uri == "tool_result://get_user" - - def test_resource_uri(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource( - resource_request_id="r1", - uri="file:///data/report.csv", - resource_type=ResourceType.FILE, - ) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.uri == "file:///data/report.csv" - - def test_resource_ref_uri(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ResourceRefContentPart( - content=ResourceReference( - resource_request_id="r1", - uri="db://users/42", - resource_type=ResourceType.DATABASE, - ) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.uri == "db://users/42" - - def test_prompt_request_uri(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - PromptRequestContentPart( - content=PromptRequest(prompt_request_id="p1", name="summarize", server_id="prompt-svc") - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.uri == "prompt://prompt-svc/summarize" - - def test_prompt_result_uri(self): - msg = Message( - role=Role.TOOL, - content=[PromptResultContentPart(content=PromptResult(prompt_request_id="p1", prompt_name="summarize"))], - ) - view = list(iter_views(msg))[0] - assert view.uri == "prompt_result://summarize" - - def test_text_uri_is_none(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.uri is None - - -# --------------------------------------------------------------------------- -# Name -# --------------------------------------------------------------------------- - - -class TestName: - """Tests for the name property.""" - - def test_tool_call_name(self): - msg = Message( - role=Role.ASSISTANT, - content=[ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="get_user", arguments={}))], - ) - assert list(iter_views(msg))[0].name == "get_user" - - def test_tool_result_name(self): - msg = Message( - role=Role.TOOL, - content=[ToolResultContentPart(content=ToolResult(tool_call_id="tc1", tool_name="get_user"))], - ) - assert list(iter_views(msg))[0].name == "get_user" - - def test_prompt_request_name(self): - msg = Message( - role=Role.ASSISTANT, - content=[PromptRequestContentPart(content=PromptRequest(prompt_request_id="p1", name="summarize"))], - ) - assert list(iter_views(msg))[0].name == "summarize" - - def test_prompt_result_name(self): - msg = Message( - role=Role.TOOL, - content=[PromptResultContentPart(content=PromptResult(prompt_request_id="p1", prompt_name="summarize"))], - ) - assert list(iter_views(msg))[0].name == "summarize" - - def test_text_name_is_none(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - assert list(iter_views(msg))[0].name is None - - -# --------------------------------------------------------------------------- -# Action -# --------------------------------------------------------------------------- - - -class TestAction: - """Tests for the action property.""" - - def test_action_mapping(self): - pairs = [ - (TextContent(text="hi"), Role.USER, ViewAction.SEND), - (ThinkingContent(text="hmm"), Role.ASSISTANT, ViewAction.GENERATE), - ( - ToolCallContentPart(content=ToolCall(tool_call_id="t", name="x", arguments={})), - Role.ASSISTANT, - ViewAction.EXECUTE, - ), - (ToolResultContentPart(content=ToolResult(tool_call_id="t", tool_name="x")), Role.TOOL, ViewAction.RECEIVE), - ( - ResourceContentPart( - content=Resource(resource_request_id="r", uri="f:///a", resource_type=ResourceType.FILE) - ), - Role.TOOL, - ViewAction.READ, - ), - ( - ResourceRefContentPart( - content=ResourceReference(resource_request_id="r", uri="f:///a", resource_type=ResourceType.FILE) - ), - Role.ASSISTANT, - ViewAction.READ, - ), - ( - PromptRequestContentPart(content=PromptRequest(prompt_request_id="p", name="s")), - Role.ASSISTANT, - ViewAction.INVOKE, - ), - ( - PromptResultContentPart(content=PromptResult(prompt_request_id="p", prompt_name="s")), - Role.TOOL, - ViewAction.RECEIVE, - ), - (ImageContentPart(content=ImageSource(type="url", data="http://img")), Role.USER, ViewAction.SEND), - ] - for part, role, expected_action in pairs: - msg = Message(role=role, content=[part]) - view = list(iter_views(msg))[0] - assert view.action == expected_action, f"Expected {expected_action} for {part.content_type}" - - -# --------------------------------------------------------------------------- -# Direction -# --------------------------------------------------------------------------- - - -class TestDirection: - """Tests for is_pre / is_post direction logic.""" - - def test_tool_call_is_pre(self): - msg = Message( - role=Role.ASSISTANT, - content=[ToolCallContentPart(content=ToolCall(tool_call_id="t", name="x", arguments={}))], - ) - view = list(iter_views(msg))[0] - assert view.is_pre is True - assert view.is_post is False - - def test_tool_result_is_post(self): - msg = Message( - role=Role.TOOL, content=[ToolResultContentPart(content=ToolResult(tool_call_id="t", tool_name="x"))] - ) - view = list(iter_views(msg))[0] - assert view.is_pre is False - assert view.is_post is True - - def test_prompt_request_is_pre(self): - msg = Message( - role=Role.ASSISTANT, - content=[PromptRequestContentPart(content=PromptRequest(prompt_request_id="p", name="s"))], - ) - view = list(iter_views(msg))[0] - assert view.is_pre is True - - def test_prompt_result_is_post(self): - msg = Message( - role=Role.TOOL, - content=[PromptResultContentPart(content=PromptResult(prompt_request_id="p", prompt_name="s"))], - ) - view = list(iter_views(msg))[0] - assert view.is_post is True - - def test_resource_ref_is_pre(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ResourceRefContentPart( - content=ResourceReference(resource_request_id="r", uri="f:///a", resource_type=ResourceType.FILE) - ), - ], - ) - view = list(iter_views(msg))[0] - assert view.is_pre is True - - def test_resource_is_post(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource(resource_request_id="r", uri="f:///a", resource_type=ResourceType.FILE) - ), - ], - ) - view = list(iter_views(msg))[0] - assert view.is_post is True - - def test_user_text_is_pre(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.is_pre is True - assert view.is_post is False - - def test_assistant_text_is_post(self): - msg = Message(role=Role.ASSISTANT, content=[TextContent(text="hello")]) - view = list(iter_views(msg))[0] - assert view.is_pre is False - assert view.is_post is True - - def test_system_text_is_pre(self): - msg = Message(role=Role.SYSTEM, content=[TextContent(text="instructions")]) - view = list(iter_views(msg))[0] - assert view.is_pre is True - - def test_developer_text_is_pre(self): - msg = Message(role=Role.DEVELOPER, content=[TextContent(text="hints")]) - view = list(iter_views(msg))[0] - assert view.is_pre is True - - def test_tool_text_is_post(self): - msg = Message(role=Role.TOOL, content=[TextContent(text="result text")]) - view = list(iter_views(msg))[0] - assert view.is_pre is False - assert view.is_post is True - - -# --------------------------------------------------------------------------- -# Entity Type Helpers -# --------------------------------------------------------------------------- - - -class TestEntityTypeHelpers: - """Tests for is_tool, is_prompt, is_resource, is_text, is_media.""" - - def test_is_tool(self): - msg = Message( - role=Role.ASSISTANT, - content=[ToolCallContentPart(content=ToolCall(tool_call_id="t", name="x", arguments={}))], - ) - assert list(iter_views(msg))[0].is_tool is True - - def test_is_tool_result(self): - msg = Message( - role=Role.TOOL, content=[ToolResultContentPart(content=ToolResult(tool_call_id="t", tool_name="x"))] - ) - assert list(iter_views(msg))[0].is_tool is True - - def test_is_prompt(self): - msg = Message( - role=Role.ASSISTANT, - content=[PromptRequestContentPart(content=PromptRequest(prompt_request_id="p", name="s"))], - ) - assert list(iter_views(msg))[0].is_prompt is True - - def test_is_resource(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource(resource_request_id="r", uri="f:///a", resource_type=ResourceType.FILE) - ), - ], - ) - assert list(iter_views(msg))[0].is_resource is True - - def test_is_text(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - assert list(iter_views(msg))[0].is_text is True - - def test_is_text_thinking(self): - msg = Message(role=Role.ASSISTANT, content=[ThinkingContent(text="hmm")]) - assert list(iter_views(msg))[0].is_text is True - - def test_is_media(self): - for part in [ - ImageContentPart(content=ImageSource(type="url", data="http://img")), - VideoContentPart(content=VideoSource(type="url", data="http://vid")), - AudioContentPart(content=AudioSource(type="url", data="http://aud")), - DocumentContentPart(content=DocumentSource(type="url", data="http://doc")), - ]: - msg = Message(role=Role.USER, content=[part]) - assert list(iter_views(msg))[0].is_media is True - - def test_text_is_not_media(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - assert list(iter_views(msg))[0].is_media is False - - -# --------------------------------------------------------------------------- -# Flat Accessors -# --------------------------------------------------------------------------- - - -class TestFlatAccessors: - """Tests for capability-gated flat accessors.""" - - def test_base_tier(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.environment == "production" - assert view.request_id == "req-001" - - def test_subject(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.subject.id == "user-alice" - assert view.subject.type == SubjectType.USER - - def test_roles(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert "admin" in view.roles - assert "hr-manager" in view.roles - - def test_permissions(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert "read:compensation" in view.permissions - - def test_teams(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert "hr-team" in view.teams - - def test_headers(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.headers["Content-Type"] == "application/json" - - def test_labels(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert "CONFIDENTIAL" in view.labels - - def test_agent_accessors(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.agent_input == "Show me Alice's compensation" - assert view.session_id == "sess-001" - assert view.conversation_id == "conv-001" - assert view.turn == 2 - assert view.agent_id == "main-agent" - assert view.parent_agent_id == "orchestrator" - - def test_object_profile(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.object is not None - assert view.object.managed_by == "tool" - assert view.object.permissions == ["read:compensation"] - assert view.object.trust_domain == "internal" - assert view.object.data_scope == ["salary", "bonus"] - - def test_data_policy(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.data_policy is not None - assert "PII" in view.data_policy.apply_labels - assert "export" in view.data_policy.denied_actions - assert view.data_policy.retention.policy == "session" - - def test_no_extensions(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.environment is None - assert view.request_id is None - assert view.subject is None - assert view.roles == frozenset() - assert view.permissions == frozenset() - assert view.teams == frozenset() - assert view.headers == {} - assert view.labels == frozenset() - assert view.agent_input is None - assert view.session_id is None - assert view.conversation_id is None - assert view.turn is None - assert view.agent_id is None - assert view.parent_agent_id is None - assert view.object is None - assert view.data_policy is None - - def test_object_resolves_by_name(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="tool_a", arguments={})), - ToolCallContentPart(content=ToolCall(tool_call_id="tc2", name="tool_b", arguments={})), - ], - ) - ext = Extensions( - security=SecurityExtension( - objects={"tool_a": ObjectSecurityProfile(managed_by="host")}, - ), - ) - views = list(iter_views(msg, extensions=ext)) - assert views[0].object is not None - assert views[0].object.managed_by == "host" - assert views[1].object is None - - -# --------------------------------------------------------------------------- -# Helper Methods -# --------------------------------------------------------------------------- - - -class TestHelperMethods: - """Tests for helper methods on MessageView.""" - - def test_has_role(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.has_role("admin") is True - assert view.has_role("viewer") is False - - def test_has_permission(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.has_permission("read:compensation") is True - assert view.has_permission("write:users") is False - - def test_has_label(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.has_label("CONFIDENTIAL") is True - assert view.has_label("SECRET") is False - - def test_has_header(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.has_header("Content-Type") is True - assert view.has_header("content-type") is True - assert view.has_header("X-Missing") is False - - def test_get_header_case_insensitive(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.get_header("content-type") == "application/json" - assert view.get_header("CONTENT-TYPE") == "application/json" - - def test_get_header_default(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.get_header("X-Missing") is None - assert view.get_header("X-Missing", "fallback") == "fallback" - - def test_get_arg(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.get_arg("employee_id") == "emp-42" - assert view.get_arg("missing") is None - assert view.get_arg("missing", "default") == "default" - - def test_has_arg(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.has_arg("employee_id") is True - assert view.has_arg("missing") is False - - def test_has_arg_text_view(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.has_arg("anything") is False - - def test_matches_uri_pattern(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - assert view.matches_uri_pattern("tool://hr-server/*") is True - assert view.matches_uri_pattern("tool://hr-server/get_*") is True - assert view.matches_uri_pattern("tool://other/*") is False - assert view.matches_uri_pattern("tool://**") is True - - def test_matches_uri_pattern_no_uri(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.matches_uri_pattern("*") is False - - def test_has_content(self, simple_assistant_msg): - views = list(iter_views(simple_assistant_msg)) - assert views[0].has_content() is True - assert views[1].has_content() is True - assert views[2].has_content() is True - - -# --------------------------------------------------------------------------- -# Type-Specific Properties -# --------------------------------------------------------------------------- - - -class TestProperties: - """Tests for type-specific properties.""" - - def test_tool_call_properties(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="test", namespace="ns", arguments={})) - ], - ) - view = list(iter_views(msg))[0] - assert view.get_property("namespace") == "ns" - assert view.get_property("tool_id") == "tc1" - props = view.properties - assert props["namespace"] == "ns" - assert props["tool_id"] == "tc1" - - def test_tool_result_properties(self): - msg = Message( - role=Role.TOOL, - content=[ToolResultContentPart(content=ToolResult(tool_call_id="tc1", tool_name="test", is_error=True))], - ) - view = list(iter_views(msg))[0] - assert view.get_property("is_error") is True - assert view.get_property("tool_name") == "test" - - def test_resource_properties(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource( - resource_request_id="r1", - uri="f:///a", - resource_type=ResourceType.FILE, - version="v2", - annotations={"key": "val"}, - ) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.get_property("resource_type") == "file" - assert view.get_property("version") == "v2" - assert view.get_property("annotations") == {"key": "val"} - - def test_prompt_result_properties(self): - msg = Message( - role=Role.TOOL, - content=[ - PromptResultContentPart( - content=PromptResult( - prompt_request_id="p1", - prompt_name="s", - messages=[ - Message(role=Role.USER, content=[TextContent(text="m1")]), - Message(role=Role.ASSISTANT, content=[TextContent(text="m2")]), - ], - is_error=False, - ) - ) - ], - ) - view = list(iter_views(msg))[0] - assert view.get_property("is_error") is False - assert view.get_property("message_count") == 2 - - def test_get_property_default(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.get_property("anything") is None - assert view.get_property("anything", "fallback") == "fallback" - - def test_empty_properties_for_text(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.properties == {} - - -# --------------------------------------------------------------------------- -# Misc Properties -# --------------------------------------------------------------------------- - - -class TestMiscProperties: - """Tests for mime_type, size_bytes, args.""" - - def test_mime_type_resource(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource( - resource_request_id="r1", - uri="f:///a", - resource_type=ResourceType.FILE, - mime_type="text/csv", - ) - ) - ], - ) - assert list(iter_views(msg))[0].mime_type == "text/csv" - - def test_mime_type_image(self): - msg = Message( - role=Role.USER, - content=[ - ImageContentPart(content=ImageSource(type="url", data="http://img", media_type="image/png")), - ], - ) - assert list(iter_views(msg))[0].mime_type == "image/png" - - def test_mime_type_text_none(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - assert list(iter_views(msg))[0].mime_type is None - - def test_size_bytes_text(self): - msg = Message(role=Role.USER, content=[TextContent(text="hello")]) - assert list(iter_views(msg))[0].size_bytes == 5 - - def test_size_bytes_resource_explicit(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource( - resource_request_id="r1", - uri="f:///a", - resource_type=ResourceType.FILE, - size_bytes=1024, - ) - ) - ], - ) - assert list(iter_views(msg))[0].size_bytes == 1024 - - def test_size_bytes_resource_from_content(self): - msg = Message( - role=Role.TOOL, - content=[ - ResourceContentPart( - content=Resource( - resource_request_id="r1", - uri="f:///a", - resource_type=ResourceType.FILE, - content="hello", - ) - ) - ], - ) - assert list(iter_views(msg))[0].size_bytes == 5 - - def test_args_tool_call(self): - msg = Message( - role=Role.ASSISTANT, - content=[ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="x", arguments={"a": 1, "b": 2}))], - ) - view = list(iter_views(msg))[0] - assert view.args == {"a": 1, "b": 2} - - def test_args_text_none(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - assert list(iter_views(msg))[0].args is None - - -# --------------------------------------------------------------------------- -# Serialization -# --------------------------------------------------------------------------- - - -class TestSerialization: - """Tests for to_dict() and to_opa_input().""" - - def test_to_dict_basic(self, simple_assistant_msg): - view = list(iter_views(simple_assistant_msg))[2] - d = view.to_dict() - assert d["kind"] == "tool_call" - assert d["role"] == "assistant" - assert d["is_pre"] is True - assert d["is_post"] is False - assert d["action"] == "execute" - assert d["name"] == "execute_sql" - assert d["uri"] == "tool://_/execute_sql" - - def test_to_dict_strips_sensitive_headers(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - d = view.to_dict() - headers = d["extensions"].get("headers", {}) - assert "Authorization" not in headers - assert "Cookie" not in headers - assert "Content-Type" in headers - - def test_to_dict_includes_extensions(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - d = view.to_dict() - ext = d["extensions"] - assert ext["environment"] == "production" - assert ext["subject"]["id"] == "user-alice" - assert "CONFIDENTIAL" in ext["labels"] - assert ext["object"]["managed_by"] == "tool" - assert "PII" in ext["data"]["apply_labels"] - assert ext["agent"]["input"] == "Show me Alice's compensation" - - def test_to_dict_exclude_content(self, simple_assistant_msg): - view = list(iter_views(simple_assistant_msg))[0] - d = view.to_dict(include_content=False) - assert "content" not in d - assert "size_bytes" not in d - - def test_to_dict_exclude_context(self, full_msg): - view = list(iter_views(full_msg[0], extensions=full_msg[1]))[0] - d = view.to_dict(include_context=False) - assert "extensions" not in d - - def test_to_opa_input(self, simple_assistant_msg): - view = list(iter_views(simple_assistant_msg))[2] - opa = view.to_opa_input() - assert "input" in opa - assert opa["input"]["kind"] == "tool_call" - - def test_to_dict_no_extensions(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - d = view.to_dict() - assert "extensions" not in d - - -# --------------------------------------------------------------------------- -# Repr -# --------------------------------------------------------------------------- - - -class TestRepr: - """Tests for __repr__.""" - - def test_repr_text(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - r = repr(view) - assert "kind=text" in r - assert "role=user" in r - assert "pre" in r - - def test_repr_tool_call(self): - msg = Message( - role=Role.ASSISTANT, - content=[ToolCallContentPart(content=ToolCall(tool_call_id="tc1", name="test", arguments={}))], - ) - view = list(iter_views(msg))[0] - r = repr(view) - assert "tool_call" in r - assert "tool://_/test" in r - - -# --------------------------------------------------------------------------- -# Hook property -# --------------------------------------------------------------------------- - - -class TestHookProperty: - """Tests for the hook property on MessageView.""" - - def test_hook_none_by_default(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.hook is None - - def test_hook_passed_through(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg, hook="llm_input"))[0] - assert view.hook == "llm_input" - - def test_hook_in_to_dict(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg, hook="tool_pre_invoke"))[0] - d = view.to_dict() - assert d["hook"] == "tool_pre_invoke" - - def test_hook_absent_from_to_dict_when_none(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - d = view.to_dict() - assert "hook" not in d - - -# --------------------------------------------------------------------------- -# Content edge cases -# --------------------------------------------------------------------------- - - -class TestContentEdgeCases: - """Tests for content property edge cases (json fallbacks).""" - - def test_tool_call_non_serializable_args(self): - """Tool call with non-JSON-serializable arguments falls back to str().""" - tc = ToolCall(tool_call_id="tc1", name="test", arguments={"key": "val"}) - msg = Message(role=Role.ASSISTANT, content=[ToolCallContentPart(content=tc)]) - view = list(iter_views(msg))[0] - assert view.content is not None - - def test_prompt_request_content(self): - pr = PromptRequest( - prompt_request_id="pr1", - name="test", - arguments={"key": "val"}, - ) - msg = Message(role=Role.USER, content=[PromptRequestContentPart(content=pr)]) - view = list(iter_views(msg))[0] - assert '"key"' in view.content - - def test_prompt_result_content(self): - pr = PromptResult( - prompt_request_id="pr1", - prompt_name="test", - content="rendered text", - ) - msg = Message(role=Role.TOOL, content=[PromptResultContentPart(content=pr)]) - view = list(iter_views(msg))[0] - assert view.content == "rendered text" - - def test_resource_blob_size(self): - """Resource with blob but no content still reports size_bytes.""" - res = Resource( - resource_request_id="r1", - uri="file:///a.bin", - resource_type=ResourceType.FILE, - blob=b"\x00\x01\x02", - ) - msg = Message(role=Role.TOOL, content=[ResourceContentPart(content=res)]) - view = list(iter_views(msg))[0] - assert view.content is None - assert view.size_bytes == 3 - - def test_resource_explicit_size(self): - """Resource with explicit size_bytes uses that value.""" - res = Resource( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - content="hello", - size_bytes=999, - ) - msg = Message(role=Role.TOOL, content=[ResourceContentPart(content=res)]) - view = list(iter_views(msg))[0] - assert view.size_bytes == 999 - - def test_to_dict_no_content_with_blob_size(self): - """to_dict includes size_bytes even when content is None (blob path).""" - res = Resource( - resource_request_id="r1", - uri="file:///a.bin", - resource_type=ResourceType.FILE, - blob=b"\x00\x01", - ) - msg = Message(role=Role.TOOL, content=[ResourceContentPart(content=res)]) - view = list(iter_views(msg))[0] - d = view.to_dict() - assert "content" not in d - assert d["size_bytes"] == 2 - - -# --------------------------------------------------------------------------- -# Properties edge cases -# --------------------------------------------------------------------------- - - -class TestPropertiesEdgeCases: - """Tests for properties on various view kinds.""" - - def test_resource_properties(self): - res = Resource( - resource_request_id="r1", - uri="file:///a.txt", - resource_type=ResourceType.FILE, - content="hi", - version="v1", - annotations={"label": "pii"}, - ) - msg = Message(role=Role.TOOL, content=[ResourceContentPart(content=res)]) - view = list(iter_views(msg))[0] - props = view.properties - assert props["resource_type"] == "file" - assert props["version"] == "v1" - assert props["annotations"] == {"label": "pii"} - - def test_tool_call_properties(self): - tc = ToolCall( - tool_call_id="tc1", - name="test", - namespace="ns", - arguments={}, - ) - msg = Message(role=Role.ASSISTANT, content=[ToolCallContentPart(content=tc)]) - view = list(iter_views(msg))[0] - props = view.properties - assert props["namespace"] == "ns" - assert props["tool_id"] == "tc1" - - def test_tool_result_properties(self): - tr = ToolResult( - tool_call_id="tc1", - tool_name="test", - content="result", - is_error=True, - ) - msg = Message(role=Role.TOOL, content=[ToolResultContentPart(content=tr)]) - view = list(iter_views(msg))[0] - props = view.properties - assert props["is_error"] is True - assert props["tool_name"] == "test" - - def test_prompt_request_properties(self): - pr = PromptRequest( - prompt_request_id="pr1", - name="test", - server_id="srv1", - ) - msg = Message(role=Role.USER, content=[PromptRequestContentPart(content=pr)]) - view = list(iter_views(msg))[0] - props = view.properties - assert props["server_id"] == "srv1" - - -# --------------------------------------------------------------------------- -# Headers immutability -# --------------------------------------------------------------------------- - - -class TestHeadersImmutability: - """Tests that headers returns a read-only mapping.""" - - def test_headers_not_mutable(self): - ext = Extensions(http=HttpExtension(headers={"Authorization": "Bearer tok"})) - msg = Message( - role=Role.USER, - content=[TextContent(text="hi")], - ) - view = list(iter_views(msg, extensions=ext))[0] - with pytest.raises(TypeError): - view.headers["new_key"] = "val" - - -# --------------------------------------------------------------------------- -# get_arg / has_arg -# --------------------------------------------------------------------------- - - -class TestArgHelpers: - """Tests for get_arg and has_arg.""" - - def test_get_arg_on_non_tool(self): - msg = Message(role=Role.USER, content=[TextContent(text="hi")]) - view = list(iter_views(msg))[0] - assert view.get_arg("anything") is None - assert view.get_arg("anything", "fallback") == "fallback" diff --git a/tests/unit/cpex/framework/extensions/__init__.py b/tests/unit/cpex/framework/extensions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/cpex/framework/extensions/test_delegation.py b/tests/unit/cpex/framework/extensions/test_delegation.py deleted file mode 100644 index 1b878e43..00000000 --- a/tests/unit/cpex/framework/extensions/test_delegation.py +++ /dev/null @@ -1,134 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for DelegationExtension and DelegationHop. - -Covers: -- DelegationHop construction and timestamp (timezone-aware) -- DelegationExtension.with_new_hop() monotonic chain growth -- Scope narrowing on hop creation -- age_seconds calculation -- Immutability of frozen models -""" - -from datetime import UTC, datetime, timedelta - -import pytest - -from cpex.framework.extensions.delegation import DelegationExtension, DelegationHop - - -class TestDelegationHop: - """Tests for DelegationHop construction.""" - - def test_basic_construction(self): - hop = DelegationHop(subject_id="alice@corp.com", subject_type="user") - assert hop.subject_id == "alice@corp.com" - assert hop.subject_type == "user" - assert hop.audience is None - assert hop.scopes_granted == () - assert hop.from_cache is False - - def test_timestamp_is_timezone_aware(self): - hop = DelegationHop(subject_id="alice", subject_type="user") - assert hop.timestamp.tzinfo is not None - - def test_with_scopes(self): - hop = DelegationHop( - subject_id="alice", - subject_type="user", - scopes_granted=("read:compensation", "read:directory"), - audience="hr-service", - strategy="token_exchange", - ) - assert len(hop.scopes_granted) == 2 - assert "read:compensation" in hop.scopes_granted - assert hop.audience == "hr-service" - assert hop.strategy == "token_exchange" - - def test_frozen(self): - hop = DelegationHop(subject_id="alice", subject_type="user") - with pytest.raises(Exception): # ValidationError for frozen - hop.subject_id = "bob" - - -class TestDelegationExtension: - """Tests for DelegationExtension.""" - - def test_empty_extension(self): - ext = DelegationExtension() - assert ext.delegated is False - assert ext.depth == 0 - assert ext.chain == () - assert ext.origin_subject_id is None - assert ext.actor_subject_id is None - - def test_with_new_hop_first_hop(self): - ext = DelegationExtension() - hop = DelegationHop( - subject_id="alice@corp.com", - subject_type="user", - scopes_granted=("read",), - ) - new_ext = ext.with_new_hop(hop) - - assert new_ext.delegated is True - assert new_ext.depth == 1 - assert len(new_ext.chain) == 1 - assert new_ext.origin_subject_id == "alice@corp.com" - assert new_ext.actor_subject_id == "alice@corp.com" - - def test_with_new_hop_preserves_origin(self): - ext = DelegationExtension() - hop1 = DelegationHop(subject_id="alice", subject_type="user", scopes_granted=("read", "write")) - hop2 = DelegationHop(subject_id="agent-1", subject_type="agent", scopes_granted=("read",)) - - ext1 = ext.with_new_hop(hop1) - ext2 = ext1.with_new_hop(hop2) - - assert ext2.depth == 2 - assert ext2.origin_subject_id == "alice" # preserved from first hop - assert ext2.actor_subject_id == "agent-1" # updated to latest hop - - def test_with_new_hop_is_immutable(self): - """with_new_hop returns a NEW extension — original is unchanged.""" - ext = DelegationExtension() - hop = DelegationHop(subject_id="alice", subject_type="user") - new_ext = ext.with_new_hop(hop) - - assert ext.depth == 0 # original unchanged - assert new_ext.depth == 1 # new one has the hop - - def test_with_new_hop_chain_grows_monotonically(self): - ext = DelegationExtension() - hop1 = DelegationHop(subject_id="alice", subject_type="user") - hop2 = DelegationHop(subject_id="bob", subject_type="agent") - hop3 = DelegationHop(subject_id="charlie", subject_type="service") - - ext1 = ext.with_new_hop(hop1) - ext2 = ext1.with_new_hop(hop2) - ext3 = ext2.with_new_hop(hop3) - - assert ext3.depth == 3 - assert ext3.chain[0].subject_id == "alice" - assert ext3.chain[1].subject_id == "bob" - assert ext3.chain[2].subject_id == "charlie" - - def test_age_seconds_increases(self): - """age_seconds should reflect time since first hop.""" - # Create a hop with a timestamp in the past - old_hop = DelegationHop( - subject_id="alice", - subject_type="user", - timestamp=datetime.now(UTC) - timedelta(seconds=10), - ) - ext = DelegationExtension() - ext1 = ext.with_new_hop(old_hop) - - # Add a second hop — age should be > 0 - hop2 = DelegationHop(subject_id="bob", subject_type="agent") - ext2 = ext1.with_new_hop(hop2) - assert ext2.age_seconds >= 9.0 # at least ~10s minus timing - - def test_frozen(self): - ext = DelegationExtension() - with pytest.raises(Exception): - ext.delegated = True diff --git a/tests/unit/cpex/framework/extensions/test_extensions.py b/tests/unit/cpex/framework/extensions/test_extensions.py deleted file mode 100644 index d3ccf298..00000000 --- a/tests/unit/cpex/framework/extensions/test_extensions.py +++ /dev/null @@ -1,622 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/extensions/test_extensions.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for extension models. -""" - -# Standard - -# Third-Party -import pytest - -# First-Party -from cpex.framework.extensions.agent import AgentExtension, ConversationContext -from cpex.framework.extensions.completion import ( - CompletionExtension, - StopReason, - TokenUsage, -) -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.framework import FrameworkExtension -from cpex.framework.extensions.http import HttpExtension -from cpex.framework.extensions.llm import LLMExtension -from cpex.framework.extensions.mcp import ( - MCPExtension, - PromptMetadata, - ResourceMetadata, - ToolMetadata, -) -from cpex.framework.extensions.provenance import ProvenanceExtension -from cpex.framework.extensions.request import RequestExtension -from cpex.framework.extensions.security import ( - DataPolicy, - ObjectSecurityProfile, - RetentionPolicy, - SecurityExtension, - SubjectExtension, - SubjectType, -) - -# --------------------------------------------------------------------------- -# RequestExtension -# --------------------------------------------------------------------------- - - -class TestRequestExtension: - """Tests for RequestExtension.""" - - def test_creation(self): - ext = RequestExtension( - environment="production", - request_id="req-001", - timestamp="2025-01-15T10:30:00Z", - ) - assert ext.environment == "production" - assert ext.request_id == "req-001" - assert ext.timestamp == "2025-01-15T10:30:00Z" - - def test_defaults(self): - ext = RequestExtension() - assert ext.environment is None - assert ext.request_id is None - assert ext.timestamp is None - assert ext.trace_id is None - assert ext.span_id is None - - def test_frozen(self): - ext = RequestExtension(environment="dev") - with pytest.raises(Exception): - ext.environment = "production" - - def test_model_copy(self): - ext = RequestExtension(environment="dev") - updated = ext.model_copy(update={"environment": "production"}) - assert ext.environment == "dev" - assert updated.environment == "production" - - def test_tracing_fields(self): - ext = RequestExtension( - trace_id="trace-abc", - span_id="span-123", - ) - assert ext.trace_id == "trace-abc" - assert ext.span_id == "span-123" - - -# --------------------------------------------------------------------------- -# AgentExtension -# --------------------------------------------------------------------------- - - -class TestConversationContext: - """Tests for ConversationContext.""" - - def test_creation(self): - ctx = ConversationContext( - summary="User asked about revenue.", - topics=["revenue", "Q4"], - ) - assert ctx.summary == "User asked about revenue." - assert ctx.topics == ["revenue", "Q4"] - - def test_defaults(self): - ctx = ConversationContext() - assert ctx.history == [] - assert ctx.summary is None - assert ctx.topics == [] - - def test_frozen(self): - ctx = ConversationContext(summary="test") - with pytest.raises(Exception): - ctx.summary = "modified" - - -class TestAgentExtension: - """Tests for AgentExtension.""" - - def test_creation(self): - ext = AgentExtension( - input="What is the weather?", - session_id="sess-001", - conversation_id="conv-042", - turn=3, - agent_id="weather-agent", - ) - assert ext.input == "What is the weather?" - assert ext.session_id == "sess-001" - assert ext.turn == 3 - - def test_defaults(self): - ext = AgentExtension() - assert ext.input is None - assert ext.session_id is None - assert ext.conversation_id is None - assert ext.turn is None - assert ext.agent_id is None - assert ext.parent_agent_id is None - assert ext.conversation is None - - def test_multi_agent_lineage(self): - ext = AgentExtension( - agent_id="sub-agent-01", - parent_agent_id="main-agent", - ) - assert ext.parent_agent_id == "main-agent" - - def test_with_conversation(self): - conv = ConversationContext(summary="Prior context", topics=["weather"]) - ext = AgentExtension(conversation=conv) - assert ext.conversation.summary == "Prior context" - - -# --------------------------------------------------------------------------- -# HttpExtension -# --------------------------------------------------------------------------- - - -class TestHttpExtension: - """Tests for HttpExtension.""" - - def test_creation(self): - ext = HttpExtension( - headers={"Content-Type": "application/json", "X-Request-ID": "req-001"}, - ) - assert ext.headers["Content-Type"] == "application/json" - - def test_defaults(self): - ext = HttpExtension() - assert ext.headers == {} - - def test_frozen(self): - ext = HttpExtension(headers={"X-Test": "value"}) - with pytest.raises(Exception): - ext.headers = {} - - def test_model_copy_add_header(self): - ext = HttpExtension(headers={"X-Test": "value"}) - updated = ext.model_copy( - update={"headers": {**ext.headers, "X-New": "added"}}, - ) - assert "X-New" in updated.headers - assert "X-New" not in ext.headers - - -# --------------------------------------------------------------------------- -# SecurityExtension -# --------------------------------------------------------------------------- - - -class TestSubjectType: - """Tests for SubjectType enum.""" - - def test_values(self): - assert SubjectType.USER.value == "user" - assert SubjectType.AGENT.value == "agent" - assert SubjectType.SERVICE.value == "service" - assert SubjectType.SYSTEM.value == "system" - - def test_member_count(self): - assert len(SubjectType) == 4 - - -class TestSubjectExtension: - """Tests for SubjectExtension.""" - - def test_creation(self): - subject = SubjectExtension( - id="user-alice", - type=SubjectType.USER, - roles=frozenset({"admin", "developer"}), - permissions=frozenset({"db.read", "tools.execute"}), - ) - assert subject.id == "user-alice" - assert subject.type == SubjectType.USER - assert "admin" in subject.roles - assert "db.read" in subject.permissions - - def test_defaults(self): - subject = SubjectExtension(id="svc-1", type=SubjectType.SERVICE) - assert subject.roles == frozenset() - assert subject.permissions == frozenset() - assert subject.teams == frozenset() - assert subject.claims == {} - - def test_frozen_sets(self): - subject = SubjectExtension( - id="test", - type=SubjectType.USER, - roles=frozenset({"admin"}), - ) - assert isinstance(subject.roles, frozenset) - assert isinstance(subject.permissions, frozenset) - assert isinstance(subject.teams, frozenset) - - -class TestObjectSecurityProfile: - """Tests for ObjectSecurityProfile.""" - - def test_creation(self): - profile = ObjectSecurityProfile( - managed_by="tool", - permissions=["read:compensation"], - trust_domain="internal", - data_scope=["salary", "bonus"], - ) - assert profile.managed_by == "tool" - assert "read:compensation" in profile.permissions - assert profile.trust_domain == "internal" - - def test_defaults(self): - profile = ObjectSecurityProfile() - assert profile.managed_by == "host" - assert profile.permissions == [] - assert profile.trust_domain is None - assert profile.data_scope == [] - - -class TestRetentionPolicy: - """Tests for RetentionPolicy.""" - - def test_creation(self): - ret = RetentionPolicy( - max_age_seconds=3600, - policy="session", - ) - assert ret.max_age_seconds == 3600 - assert ret.policy == "session" - - def test_defaults(self): - ret = RetentionPolicy() - assert ret.max_age_seconds is None - assert ret.policy == "persistent" - assert ret.delete_after is None - - -class TestDataPolicy: - """Tests for DataPolicy.""" - - def test_creation(self): - policy = DataPolicy( - apply_labels=["PII", "financial"], - denied_actions=["export", "forward"], - retention=RetentionPolicy(policy="session", max_age_seconds=7200), - ) - assert "PII" in policy.apply_labels - assert "export" in policy.denied_actions - assert policy.retention.policy == "session" - - def test_defaults(self): - policy = DataPolicy() - assert policy.apply_labels == [] - assert policy.allowed_actions is None - assert policy.denied_actions == [] - assert policy.retention is None - - def test_unrestricted_vs_restricted(self): - unrestricted = DataPolicy() - restricted = DataPolicy(allowed_actions=["view", "summarize"]) - assert unrestricted.allowed_actions is None - assert restricted.allowed_actions == ["view", "summarize"] - - -class TestSecurityExtension: - """Tests for SecurityExtension.""" - - def test_creation(self): - ext = SecurityExtension( - labels=frozenset({"PII", "CONFIDENTIAL"}), - classification="confidential", - subject=SubjectExtension(id="user-1", type=SubjectType.USER), - ) - assert "PII" in ext.labels - assert ext.classification == "confidential" - assert ext.subject.id == "user-1" - - def test_defaults(self): - ext = SecurityExtension() - assert ext.labels == frozenset() - assert ext.classification is None - assert ext.subject is None - assert ext.objects == {} - assert ext.data == {} - - def test_monotonic_label_addition(self): - ext = SecurityExtension(labels=frozenset({"PII"})) - updated = ext.model_copy( - update={"labels": ext.labels | frozenset({"CONFIDENTIAL"})}, - ) - assert "PII" in updated.labels - assert "CONFIDENTIAL" in updated.labels - assert ext.labels == frozenset({"PII"}) - - def test_labels_are_frozenset(self): - ext = SecurityExtension(labels=frozenset({"PII"})) - assert isinstance(ext.labels, frozenset) - - def test_with_objects_and_data(self): - ext = SecurityExtension( - objects={ - "get_user": ObjectSecurityProfile( - managed_by="host", - permissions=["users.read"], - ), - }, - data={ - "get_user": DataPolicy( - apply_labels=["PII"], - denied_actions=["export"], - ), - }, - ) - assert ext.objects["get_user"].permissions == ["users.read"] - assert ext.data["get_user"].apply_labels == ["PII"] - - -# --------------------------------------------------------------------------- -# MCPExtension -# --------------------------------------------------------------------------- - - -class TestToolMetadata: - """Tests for ToolMetadata.""" - - def test_creation(self): - meta = ToolMetadata( - name="get_user", - description="Retrieve user by ID", - input_schema={"type": "object", "properties": {"id": {"type": "string"}}}, - ) - assert meta.name == "get_user" - assert meta.input_schema is not None - - def test_defaults(self): - meta = ToolMetadata(name="test") - assert meta.title is None - assert meta.description is None - assert meta.input_schema is None - assert meta.output_schema is None - assert meta.server_id is None - assert meta.namespace is None - assert meta.annotations == {} - - -class TestResourceMetadata: - """Tests for ResourceMetadata.""" - - def test_creation(self): - meta = ResourceMetadata( - uri="file:///data/report.csv", - name="Quarterly Report", - mime_type="text/csv", - ) - assert meta.uri == "file:///data/report.csv" - - -class TestPromptMetadata: - """Tests for PromptMetadata.""" - - def test_creation(self): - meta = PromptMetadata( - name="summarize", - arguments=[{"name": "text", "description": "Text to summarize", "required": True}], - ) - assert meta.name == "summarize" - assert meta.arguments[0]["name"] == "text" - - -class TestMCPExtension: - """Tests for MCPExtension.""" - - def test_with_tool(self): - ext = MCPExtension(tool=ToolMetadata(name="get_user")) - assert ext.tool.name == "get_user" - assert ext.resource is None - assert ext.prompt is None - - def test_with_resource(self): - ext = MCPExtension(resource=ResourceMetadata(uri="file:///test")) - assert ext.resource.uri == "file:///test" - assert ext.tool is None - - def test_with_prompt(self): - ext = MCPExtension(prompt=PromptMetadata(name="summarize")) - assert ext.prompt.name == "summarize" - - def test_defaults(self): - ext = MCPExtension() - assert ext.tool is None - assert ext.resource is None - assert ext.prompt is None - - -# --------------------------------------------------------------------------- -# CompletionExtension -# --------------------------------------------------------------------------- - - -class TestStopReason: - """Tests for StopReason enum.""" - - def test_values(self): - assert StopReason.END.value == "end" - assert StopReason.MAX_TOKENS.value == "max_tokens" - - def test_member_count(self): - assert len(StopReason) == 5 - - -class TestTokenUsage: - """Tests for TokenUsage.""" - - def test_creation(self): - usage = TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150) - assert usage.total_tokens == 150 - - -class TestCompletionExtension: - """Tests for CompletionExtension.""" - - def test_creation(self): - ext = CompletionExtension( - stop_reason=StopReason.END, - tokens=TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150), - model="gpt-4o", - latency_ms=1200, - ) - assert ext.stop_reason == StopReason.END - assert ext.tokens.total_tokens == 150 - assert ext.model == "gpt-4o" - assert ext.latency_ms == 1200 - - def test_defaults(self): - ext = CompletionExtension() - assert ext.stop_reason is None - assert ext.tokens is None - assert ext.model is None - assert ext.raw_format is None - assert ext.created_at is None - assert ext.latency_ms is None - - -# --------------------------------------------------------------------------- -# ProvenanceExtension -# --------------------------------------------------------------------------- - - -class TestProvenanceExtension: - """Tests for ProvenanceExtension.""" - - def test_creation(self): - ext = ProvenanceExtension( - source="agent:weather-bot", - message_id="msg-001", - parent_id="msg-000", - ) - assert ext.source == "agent:weather-bot" - assert ext.message_id == "msg-001" - - def test_defaults(self): - ext = ProvenanceExtension() - assert ext.source is None - assert ext.message_id is None - assert ext.parent_id is None - - -# --------------------------------------------------------------------------- -# LLMExtension -# --------------------------------------------------------------------------- - - -class TestLLMExtension: - """Tests for LLMExtension.""" - - def test_creation(self): - ext = LLMExtension( - model_id="claude-sonnet-4-20250514", - provider="anthropic", - capabilities=["vision", "tool_use"], - ) - assert ext.provider == "anthropic" - assert "tool_use" in ext.capabilities - - def test_defaults(self): - ext = LLMExtension() - assert ext.model_id is None - assert ext.provider is None - assert ext.capabilities == [] - - -# --------------------------------------------------------------------------- -# FrameworkExtension -# --------------------------------------------------------------------------- - - -class TestFrameworkExtension: - """Tests for FrameworkExtension.""" - - def test_creation(self): - ext = FrameworkExtension( - framework="langgraph", - framework_version="0.2.0", - node_id="weather_node", - graph_id="travel_planner", - ) - assert ext.framework == "langgraph" - assert ext.node_id == "weather_node" - - def test_defaults(self): - ext = FrameworkExtension() - assert ext.framework is None - assert ext.framework_version is None - assert ext.node_id is None - assert ext.graph_id is None - assert ext.metadata == {} - - -# --------------------------------------------------------------------------- -# Extensions Container -# --------------------------------------------------------------------------- - - -class TestExtensions: - """Tests for the Extensions container.""" - - def test_all_none_by_default(self): - ext = Extensions() - assert ext.request is None - assert ext.agent is None - assert ext.http is None - assert ext.security is None - assert ext.mcp is None - assert ext.completion is None - assert ext.provenance is None - assert ext.llm is None - assert ext.framework is None - assert ext.custom is None - - def test_frozen(self): - ext = Extensions() - with pytest.raises(Exception): - ext.request = RequestExtension() - - def test_model_copy(self): - ext = Extensions( - request=RequestExtension(environment="dev"), - ) - updated = ext.model_copy( - update={"request": RequestExtension(environment="production")}, - ) - assert ext.request.environment == "dev" - assert updated.request.environment == "production" - - def test_full_construction(self): - ext = Extensions( - request=RequestExtension(environment="production", request_id="req-001"), - agent=AgentExtension(input="Hello", session_id="sess-001"), - http=HttpExtension(headers={"X-Test": "value"}), - security=SecurityExtension(labels=frozenset({"PII"})), - mcp=MCPExtension(tool=ToolMetadata(name="get_user")), - completion=CompletionExtension(stop_reason=StopReason.END), - provenance=ProvenanceExtension(source="user"), - llm=LLMExtension(model_id="gpt-4o", provider="openai"), - framework=FrameworkExtension(framework="langgraph"), - custom={"debug": True}, - ) - assert ext.request.environment == "production" - assert ext.agent.input == "Hello" - assert ext.http.headers["X-Test"] == "value" - assert "PII" in ext.security.labels - assert ext.mcp.tool.name == "get_user" - assert ext.completion.stop_reason == StopReason.END - assert ext.provenance.source == "user" - assert ext.llm.provider == "openai" - assert ext.framework.framework == "langgraph" - assert ext.custom["debug"] is True - - def test_custom_extension(self): - ext = Extensions(custom={"key": "value", "nested": {"a": 1}}) - assert ext.custom["key"] == "value" - assert ext.custom["nested"]["a"] == 1 diff --git a/tests/unit/cpex/framework/extensions/test_tiers.py b/tests/unit/cpex/framework/extensions/test_tiers.py deleted file mode 100644 index 1472f440..00000000 --- a/tests/unit/cpex/framework/extensions/test_tiers.py +++ /dev/null @@ -1,753 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for cpex.framework.extensions.tiers module. - -Covers mutability tiers, capability gating, extension filtering, -tier constraint validation, and lockdown (private registry, frozen config). -""" - -# Standard -from __future__ import annotations - -# Third-Party -import pytest - -# First-Party -from cpex.framework.extensions.agent import AgentExtension -from cpex.framework.extensions.constants import SlotName -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.http import HttpExtension -from cpex.framework.extensions.request import RequestExtension -from cpex.framework.extensions.security import ( - SecurityExtension, - SubjectExtension, - SubjectType, -) -from cpex.framework.extensions.tiers import ( - AccessPolicy, - Capability, - MutabilityTier, - SlotPolicy, - TierViolationError, - _slot_registry, - filter_extensions, - merge_extensions, - validate_tier_constraints, -) - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def subject(): - return SubjectExtension( - id="user-alice", - type=SubjectType.USER, - roles=frozenset({"admin", "developer"}), - teams=frozenset({"platform"}), - claims={"sub": "alice", "iss": "idp"}, - permissions=frozenset({"tools.execute", "db.read"}), - ) - - -@pytest.fixture() -def security_ext(subject): - return SecurityExtension( - labels=frozenset({"PII", "CONFIDENTIAL"}), - classification="confidential", - subject=subject, - ) - - -@pytest.fixture() -def full_extensions(security_ext): - return Extensions( - request=RequestExtension(environment="test", request_id="req-1"), - agent=AgentExtension(session_id="sess-1"), - http=HttpExtension(headers={"Authorization": "Bearer tok"}), - security=security_ext, - custom={"key": "value"}, - ) - - -# --------------------------------------------------------------------------- -# Enum / SlotPolicy basics -# --------------------------------------------------------------------------- - - -class TestMutabilityTier: - def test_values(self): - assert MutabilityTier.IMMUTABLE.value == "immutable" - assert MutabilityTier.MONOTONIC.value == "monotonic" - assert MutabilityTier.MUTABLE.value == "mutable" - - def test_string_enum(self): - assert MutabilityTier("immutable") == MutabilityTier.IMMUTABLE - - -class TestAccessPolicy: - def test_values(self): - assert AccessPolicy.UNRESTRICTED.value == "unrestricted" - assert AccessPolicy.CAPABILITY_GATED.value == "capability_gated" - - def test_string_enum(self): - assert AccessPolicy("unrestricted") == AccessPolicy.UNRESTRICTED - - -class TestCapability: - def test_all_values(self): - assert len(Capability) == 12 # noqa: PLR2004 - assert Capability.READ_SUBJECT.value == "read_subject" - assert Capability.APPEND_LABELS.value == "append_labels" - assert Capability.WRITE_HEADERS.value == "write_headers" - assert Capability.READ_DELEGATION.value == "read_delegation" - assert Capability.APPEND_DELEGATION.value == "append_delegation" - - def test_string_enum(self): - assert Capability("read_agent") == Capability.READ_AGENT - - -class TestSlotPolicy: - def test_frozen(self): - policy = SlotPolicy(MutabilityTier.IMMUTABLE) - with pytest.raises(AttributeError): - policy.tier = MutabilityTier.MUTABLE # type: ignore[misc] - - def test_defaults(self): - policy = SlotPolicy(MutabilityTier.IMMUTABLE) - assert policy.access == AccessPolicy.UNRESTRICTED - assert policy.read_cap is None - assert policy.write_cap is None - - def test_capability_gated(self): - policy = SlotPolicy( - MutabilityTier.IMMUTABLE, - access=AccessPolicy.CAPABILITY_GATED, - read_cap=Capability.READ_AGENT, - ) - assert policy.access == AccessPolicy.CAPABILITY_GATED - assert policy.read_cap == Capability.READ_AGENT - - -class TestSlotRegistry: - def test_base_tier_slots_unrestricted(self): - for slot in ( - SlotName.REQUEST, - SlotName.PROVENANCE, - SlotName.COMPLETION, - SlotName.LLM, - SlotName.FRAMEWORK, - SlotName.MCP, - ): - policy = _slot_registry[slot] - assert policy.tier == MutabilityTier.IMMUTABLE - assert policy.access == AccessPolicy.UNRESTRICTED - assert policy.read_cap is None - assert policy.write_cap is None - - def test_agent_capability_gated(self): - policy = _slot_registry[SlotName.AGENT] - assert policy.access == AccessPolicy.CAPABILITY_GATED - assert policy.read_cap == Capability.READ_AGENT - assert policy.write_cap is None - - def test_http_capability_gated(self): - policy = _slot_registry[SlotName.HTTP] - assert policy.access == AccessPolicy.CAPABILITY_GATED - assert policy.read_cap == Capability.READ_HEADERS - assert policy.write_cap == Capability.WRITE_HEADERS - - def test_labels_monotonic_capability_gated(self): - policy = _slot_registry[SlotName.SECURITY_LABELS] - assert policy.tier == MutabilityTier.MONOTONIC - assert policy.access == AccessPolicy.CAPABILITY_GATED - assert policy.read_cap == Capability.READ_LABELS - assert policy.write_cap == Capability.APPEND_LABELS - - def test_custom_mutable_unrestricted(self): - policy = _slot_registry[SlotName.CUSTOM] - assert policy.tier == MutabilityTier.MUTABLE - assert policy.access == AccessPolicy.UNRESTRICTED - - def test_security_objects_unrestricted(self): - policy = _slot_registry[SlotName.SECURITY_OBJECTS] - assert policy.access == AccessPolicy.UNRESTRICTED - - def test_security_data_unrestricted(self): - policy = _slot_registry[SlotName.SECURITY_DATA] - assert policy.access == AccessPolicy.UNRESTRICTED - - def test_subject_subfields_capability_gated(self): - for slot in ( - SlotName.SECURITY_SUBJECT, - SlotName.SECURITY_SUBJECT_ROLES, - SlotName.SECURITY_SUBJECT_TEAMS, - SlotName.SECURITY_SUBJECT_CLAIMS, - SlotName.SECURITY_SUBJECT_PERMISSIONS, - ): - policy = _slot_registry[slot] - assert policy.access == AccessPolicy.CAPABILITY_GATED, f"{slot} should be capability-gated" - - def test_registry_is_read_only(self): - with pytest.raises(TypeError): - _slot_registry[SlotName.CUSTOM] = SlotPolicy(MutabilityTier.IMMUTABLE) # type: ignore[index] - - def test_registry_not_in_public_exports(self): - import cpex.framework.extensions as ext_pkg - - assert "SLOT_REGISTRY" not in ext_pkg.__all__ - assert "filter_extensions" not in ext_pkg.__all__ - assert "validate_tier_constraints" not in ext_pkg.__all__ - assert "SlotPolicy" not in ext_pkg.__all__ - - -# --------------------------------------------------------------------------- -# filter_extensions -# --------------------------------------------------------------------------- - - -class TestFilterExtensions: - def test_none_input(self): - assert filter_extensions(None, frozenset()) is None - - def test_no_capabilities_hides_gated_slots(self, full_extensions): - filtered = filter_extensions(full_extensions, frozenset()) - # Unrestricted slots pass through - assert filtered.request is not None - assert filtered.custom is not None - # Capability-gated slots hidden - assert filtered.agent is None - assert filtered.http is None - # Security sub-fields: subject hidden, labels hidden - assert filtered.security is not None - assert filtered.security.subject is None - assert filtered.security.labels == frozenset() - - def test_read_agent_makes_agent_visible(self, full_extensions): - caps = frozenset({"read_agent"}) - filtered = filter_extensions(full_extensions, caps) - assert filtered.agent is not None - assert filtered.agent.session_id == "sess-1" - - def test_read_headers_makes_http_visible(self, full_extensions): - caps = frozenset({"read_headers"}) - filtered = filter_extensions(full_extensions, caps) - assert filtered.http is not None - assert filtered.http.headers["Authorization"] == "Bearer tok" - - def test_write_headers_implies_read(self, full_extensions): - caps = frozenset({"write_headers"}) - filtered = filter_extensions(full_extensions, caps) - assert filtered.http is not None - - def test_append_labels_implies_read(self, full_extensions): - caps = frozenset({"append_labels"}) - filtered = filter_extensions(full_extensions, caps) - assert filtered.security.labels == frozenset({"PII", "CONFIDENTIAL"}) - - def test_read_labels_makes_labels_visible(self, full_extensions): - caps = frozenset({"read_labels"}) - filtered = filter_extensions(full_extensions, caps) - assert filtered.security.labels == frozenset({"PII", "CONFIDENTIAL"}) - - def test_no_filtering_returns_equal_object(self): - ext = Extensions(request=RequestExtension(environment="test", request_id="r1")) - result = filter_extensions(ext, frozenset()) - assert result == ext # Build-up always creates a new frozen instance - assert result is not ext - - def test_ungated_security_subfields_pass_through(self, full_extensions): - """security.objects and security.data are always visible.""" - filtered = filter_extensions(full_extensions, frozenset()) - assert filtered.security.objects == full_extensions.security.objects - assert filtered.security.data == full_extensions.security.data - - -class TestFilterSubjectGranular: - """Subject sub-field filtering: roles, teams, claims, permissions gated independently.""" - - def test_read_subject_only_hides_subfields(self, full_extensions): - caps = frozenset({"read_subject"}) - filtered = filter_extensions(full_extensions, caps) - subj = filtered.security.subject - assert subj is not None - assert subj.id == "user-alice" - assert subj.type == SubjectType.USER - # Sub-fields hidden - assert subj.roles == frozenset() - assert subj.teams == frozenset() - assert subj.claims == {} - assert subj.permissions == frozenset() - - def test_read_roles_implies_read_subject(self, full_extensions): - caps = frozenset({"read_roles"}) - filtered = filter_extensions(full_extensions, caps) - subj = filtered.security.subject - assert subj is not None - assert subj.id == "user-alice" - assert "admin" in subj.roles - # Other sub-fields still hidden - assert subj.teams == frozenset() - assert subj.claims == {} - assert subj.permissions == frozenset() - - def test_read_teams_implies_read_subject(self, full_extensions): - caps = frozenset({"read_teams"}) - filtered = filter_extensions(full_extensions, caps) - subj = filtered.security.subject - assert subj is not None - assert "platform" in subj.teams - assert subj.roles == frozenset() - - def test_read_claims_implies_read_subject(self, full_extensions): - caps = frozenset({"read_claims"}) - filtered = filter_extensions(full_extensions, caps) - subj = filtered.security.subject - assert subj is not None - assert subj.claims == {"sub": "alice", "iss": "idp"} - assert subj.roles == frozenset() - - def test_read_permissions_implies_read_subject(self, full_extensions): - caps = frozenset({"read_permissions"}) - filtered = filter_extensions(full_extensions, caps) - subj = filtered.security.subject - assert subj is not None - assert "tools.execute" in subj.permissions - assert subj.roles == frozenset() - - def test_multiple_subject_caps(self, full_extensions): - caps = frozenset({"read_roles", "read_permissions"}) - filtered = filter_extensions(full_extensions, caps) - subj = filtered.security.subject - assert "admin" in subj.roles - assert "tools.execute" in subj.permissions - assert subj.teams == frozenset() - assert subj.claims == {} - - def test_no_subject_extension_no_error(self): - ext = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - filtered = filter_extensions(ext, frozenset({"read_labels"})) - assert filtered.security.labels == frozenset({"PII"}) - assert filtered.security.subject is None - - -# --------------------------------------------------------------------------- -# validate_tier_constraints -# --------------------------------------------------------------------------- - - -class TestValidateTierConstraints: - def test_both_none(self): - validate_tier_constraints(None, None, frozenset(), "test-plugin") - - def test_no_change_passes(self, full_extensions): - validate_tier_constraints(full_extensions, full_extensions, frozenset(), "test-plugin") - - def test_immutable_no_write_cap_rejects_change(self): - before = Extensions( - request=RequestExtension(environment="prod", request_id="r1"), - ) - after = Extensions( - request=RequestExtension(environment="staging", request_id="r1"), - ) - with pytest.raises(TierViolationError) as exc_info: - validate_tier_constraints(before, after, frozenset(), "bad-plugin") - assert exc_info.value.plugin_name == "bad-plugin" - assert exc_info.value.slot == SlotName.REQUEST - assert exc_info.value.tier == MutabilityTier.IMMUTABLE - - def test_immutable_gated_rejects_without_cap(self): - before = Extensions( - http=HttpExtension(headers={"X-Foo": "bar"}), - ) - after = Extensions( - http=HttpExtension(headers={"X-Foo": "baz"}), - ) - with pytest.raises(TierViolationError) as exc_info: - validate_tier_constraints(before, after, frozenset(), "bad-plugin") - assert "write_headers" in exc_info.value.detail - - def test_immutable_gated_allows_with_write_cap(self): - before = Extensions( - http=HttpExtension(headers={"X-Foo": "bar"}), - ) - after = Extensions( - http=HttpExtension(headers={"X-Foo": "baz"}), - ) - caps = frozenset({"write_headers"}) - # Should not raise - validate_tier_constraints(before, after, caps, "good-plugin") - - def test_monotonic_superset_passes(self): - before = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - after = Extensions( - security=SecurityExtension(labels=frozenset({"PII", "CONFIDENTIAL"})), - ) - caps = frozenset({"append_labels"}) - validate_tier_constraints(before, after, caps, "good-plugin") - - def test_monotonic_removal_rejects(self): - before = Extensions( - security=SecurityExtension(labels=frozenset({"PII", "CONFIDENTIAL"})), - ) - after = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - caps = frozenset({"append_labels"}) - with pytest.raises(TierViolationError) as exc_info: - validate_tier_constraints(before, after, caps, "bad-plugin") - assert "monotonic" in str(exc_info.value) - assert exc_info.value.tier == MutabilityTier.MONOTONIC - - def test_monotonic_without_cap_rejects(self): - before = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - after = Extensions( - security=SecurityExtension(labels=frozenset({"PII", "SECRET"})), - ) - with pytest.raises(TierViolationError) as exc_info: - validate_tier_constraints(before, after, frozenset(), "bad-plugin") - assert "append_labels" in exc_info.value.detail - - def test_mutable_allows_any_change(self): - before = Extensions(custom={"key": "value"}) - after = Extensions(custom={"key": "changed", "new": "stuff"}) - validate_tier_constraints(before, after, frozenset(), "plugin") - - def test_mutable_allows_deletion(self): - before = Extensions(custom={"key": "value"}) - after = Extensions(custom=None) - validate_tier_constraints(before, after, frozenset(), "plugin") - - -class TestTierViolationError: - def test_attributes(self): - err = TierViolationError("my-plugin", SlotName.REQUEST, MutabilityTier.IMMUTABLE, "changed") - assert err.plugin_name == "my-plugin" - assert err.slot == SlotName.REQUEST - assert err.tier == MutabilityTier.IMMUTABLE - assert err.detail == "changed" - - def test_message(self): - err = TierViolationError("p", SlotName.REQUEST, MutabilityTier.IMMUTABLE, "nope") - assert "p" in str(err) - assert "immutable" in str(err) - assert "request" in str(err) - assert "nope" in str(err) - - -# --------------------------------------------------------------------------- -# merge_extensions -# --------------------------------------------------------------------------- - - -class TestMergeExtensions: - def test_none_original_returns_none(self): - output = Extensions(custom={"key": "val"}) - assert merge_extensions(None, output, frozenset(), "p") is None - - def test_none_output_returns_original(self): - original = Extensions(custom={"key": "val"}) - assert merge_extensions(original, None, frozenset(), "p") is original - - def test_no_changes_returns_original(self): - original = Extensions( - request=RequestExtension(environment="prod", request_id="r1"), - custom={"key": "val"}, - ) - output = original.model_copy() - result = merge_extensions(original, output, frozenset(), "p") - assert result is original - - def test_immutable_slots_ignored(self): - original = Extensions( - request=RequestExtension(environment="prod", request_id="r1"), - ) - output = Extensions( - request=RequestExtension(environment="staging", request_id="r1"), - ) - result = merge_extensions(original, output, frozenset(), "p") - assert result is original - assert result.request.environment == "prod" - - def test_immutable_agent_ignored(self): - original = Extensions( - agent=AgentExtension(agent_id="a1", session_id="s1"), - ) - output = Extensions( - agent=AgentExtension(agent_id="hijacked", session_id="s1"), - ) - result = merge_extensions(original, output, frozenset({"read_agent"}), "p") - assert result is original - assert result.agent.agent_id == "a1" - - def test_custom_accepted_without_cap(self): - original = Extensions(custom={"key": "val"}) - output = Extensions(custom={"key": "changed", "new": "stuff"}) - result = merge_extensions(original, output, frozenset(), "p") - assert result.custom == {"key": "changed", "new": "stuff"} - # Immutable slots unchanged - assert result.request is None - - def test_custom_deletion_accepted(self): - original = Extensions(custom={"key": "val"}) - output = Extensions(custom=None) - result = merge_extensions(original, output, frozenset(), "p") - assert result.custom is None - - def test_http_accepted_with_write_cap(self): - original = Extensions( - http=HttpExtension(headers={"X-Foo": "bar"}), - ) - output = Extensions( - http=HttpExtension(headers={"X-Foo": "baz"}), - ) - caps = frozenset({"write_headers"}) - result = merge_extensions(original, output, caps, "p") - assert result.http.headers == {"X-Foo": "baz"} - - def test_http_ignored_without_write_cap(self): - original = Extensions( - http=HttpExtension(headers={"X-Foo": "bar"}), - ) - output = Extensions( - http=HttpExtension(headers={"X-Foo": "baz"}), - ) - result = merge_extensions(original, output, frozenset({"read_headers"}), "p") - assert result is original - assert result.http.headers == {"X-Foo": "bar"} - - def test_labels_accepted_with_append_cap(self): - original = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - output = Extensions( - security=SecurityExtension(labels=frozenset({"PII", "CONFIDENTIAL"})), - ) - caps = frozenset({"append_labels"}) - result = merge_extensions(original, output, caps, "p") - assert result.security.labels == frozenset({"PII", "CONFIDENTIAL"}) - - def test_labels_ignored_without_cap(self): - original = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - output = Extensions( - security=SecurityExtension(labels=frozenset({"PII", "CONFIDENTIAL"})), - ) - result = merge_extensions(original, output, frozenset(), "p") - assert result is original - assert result.security.labels == frozenset({"PII"}) - - def test_labels_removal_rejected(self): - original = Extensions( - security=SecurityExtension(labels=frozenset({"PII", "CONFIDENTIAL"})), - ) - output = Extensions( - security=SecurityExtension(labels=frozenset({"PII"})), - ) - caps = frozenset({"append_labels"}) - with pytest.raises(TierViolationError) as exc_info: - merge_extensions(original, output, caps, "bad-plugin") - assert exc_info.value.tier == MutabilityTier.MONOTONIC - - def test_security_subject_ignored(self): - """Subject is immutable — plugin changes are discarded.""" - original = Extensions( - security=SecurityExtension( - subject=SubjectExtension( - id="alice", - type=SubjectType.USER, - roles=frozenset({"admin"}), - ), - ), - ) - output = Extensions( - security=SecurityExtension( - subject=SubjectExtension( - id="eve", - type=SubjectType.USER, - roles=frozenset({"root"}), - ), - ), - ) - caps = frozenset({"read_subject", "read_roles"}) - result = merge_extensions(original, output, caps, "p") - assert result is original - assert result.security.subject.id == "alice" - - def test_mixed_changes(self, full_extensions): - """Only writable slots are accepted in a single merge.""" - output = full_extensions.model_copy( - update={ - # Immutable — should be ignored - "request": RequestExtension(environment="hijacked", request_id="r1"), - # Mutable — should be accepted - "custom": {"injected": True}, - } - ) - result = merge_extensions(full_extensions, output, frozenset(), "p") - assert result.request.environment == full_extensions.request.environment - assert result.custom == {"injected": True} - - -# --------------------------------------------------------------------------- -# PluginConfig capabilities and frozen lockdown -# --------------------------------------------------------------------------- - - -class TestPluginConfigCapabilities: - _PLUGIN_BASE = {"name": "test-plugin", "kind": "test.Plugin"} - - def test_valid_capabilities(self): - from cpex.framework.models import PluginConfig - - conf = PluginConfig( - **self._PLUGIN_BASE, - capabilities=["read_headers", "append_labels"], - ) - assert conf.capabilities == frozenset({"read_headers", "append_labels"}) - - def test_unknown_capability_rejected(self): - from cpex.framework.models import PluginConfig - - with pytest.raises(ValueError, match="Unknown capability"): - PluginConfig(**self._PLUGIN_BASE, capabilities=["bogus_cap"]) - - def test_empty_capabilities_default(self): - from cpex.framework.models import PluginConfig - - conf = PluginConfig(**self._PLUGIN_BASE) - assert conf.capabilities == frozenset() - - def test_capabilities_serialization(self): - import orjson - - from cpex.framework.models import PluginConfig - - conf = PluginConfig( - **self._PLUGIN_BASE, - capabilities=["read_agent", "read_headers"], - ) - data = orjson.loads(orjson.dumps(conf.model_dump())) - assert sorted(data["capabilities"]) == ["read_agent", "read_headers"] - - def test_frozen_config_prevents_capability_escalation(self): - from pydantic import ValidationError - - from cpex.framework.models import PluginConfig - - conf = PluginConfig(**self._PLUGIN_BASE) - with pytest.raises(ValidationError): - conf.capabilities = frozenset({"write_headers"}) # type: ignore[misc] - - def test_frozen_config_prevents_field_mutation(self): - from pydantic import ValidationError - - from cpex.framework.models import PluginConfig - - conf = PluginConfig(**self._PLUGIN_BASE) - with pytest.raises(ValidationError): - conf.name = "hijacked" # type: ignore[misc] - - -# --------------------------------------------------------------------------- -# Defensive config copy and PluginRef trusted config -# --------------------------------------------------------------------------- - - -class TestDefensiveConfigCopy: - """Verify the trust boundary between Manager and plugins.""" - - _PLUGIN_BASE = {"name": "copy-test", "kind": "test.Plugin"} - - def test_plugin_ref_trusted_config_is_separate_from_plugin(self): - """PluginRef's trusted_config should be a different object than the plugin's config.""" - from cpex.framework.base import Plugin, PluginRef - from cpex.framework.models import PluginConfig - - original = PluginConfig(**self._PLUGIN_BASE, capabilities=["read_headers"]) - copy = original.model_copy() - plugin = Plugin(copy) - ref = PluginRef(plugin, trusted_config=original) - - # The plugin holds the copy, the ref holds the original - assert ref.trusted_config is original - assert plugin.config is copy - assert ref.trusted_config is not plugin.config - - def test_plugin_ref_reads_from_trusted_config(self): - """PluginRef properties should read from trusted_config, not from the plugin.""" - from cpex.framework.base import Plugin, PluginRef - from cpex.framework.models import PluginConfig, PluginMode - - original = PluginConfig( - **self._PLUGIN_BASE, - mode=PluginMode.CONCURRENT, - priority=42, - tags=["trusted"], - capabilities=["read_headers"], - ) - # Give the plugin a different copy with different values - plugin_copy = PluginConfig( - name="copy-test", - kind="test.Plugin", - mode=PluginMode.SEQUENTIAL, - priority=99, - tags=["untrusted"], - ) - plugin = Plugin(plugin_copy) - ref = PluginRef(plugin, trusted_config=original) - - # All properties come from trusted_config - assert ref.mode == PluginMode.CONCURRENT - assert ref.priority == 42 - assert ref.tags == ["trusted"] - assert ref.capabilities == frozenset({"read_headers"}) - - def test_plugin_ref_fallback_without_trusted_config(self): - """Without trusted_config, PluginRef falls back to plugin.config.""" - from cpex.framework.base import Plugin, PluginRef - from cpex.framework.models import PluginConfig - - config = PluginConfig(**self._PLUGIN_BASE) - plugin = Plugin(config) - ref = PluginRef(plugin) - - assert ref.trusted_config is plugin.config - - def test_model_copy_produces_equal_but_distinct_config(self): - """model_copy() should produce an equal but distinct PluginConfig.""" - from cpex.framework.models import PluginConfig - - original = PluginConfig(**self._PLUGIN_BASE, capabilities=["append_labels"]) - copy = original.model_copy() - - assert copy == original - assert copy is not original - assert copy.capabilities == original.capabilities - - def test_registry_passes_trusted_config_to_ref(self): - """PluginInstanceRegistry.register() should pass trusted_config to PluginRef.""" - from cpex.framework.base import Plugin - from cpex.framework.models import PluginConfig - from cpex.framework.registry import PluginInstanceRegistry - - original = PluginConfig(**self._PLUGIN_BASE, capabilities=["read_headers"]) - copy = original.model_copy() - plugin = Plugin(copy) - - registry = PluginInstanceRegistry() - registry.register(plugin, trusted_config=original) - - ref = registry.get_plugin("copy-test") - assert ref is not None - assert ref.trusted_config is original - assert ref.plugin.config is copy - assert ref.trusted_config is not ref.plugin.config diff --git a/tests/unit/cpex/framework/external/__init__.py b/tests/unit/cpex/framework/external/__init__.py deleted file mode 100644 index 2b7846a2..00000000 --- a/tests/unit/cpex/framework/external/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor -""" diff --git a/tests/unit/cpex/framework/external/grpc/README.md b/tests/unit/cpex/framework/external/grpc/README.md deleted file mode 100644 index 638e7d78..00000000 --- a/tests/unit/cpex/framework/external/grpc/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# gRPC External Plugin Tests - -This directory contains tests for the gRPC transport layer of the external plugin framework. - -## Test Files - -### `test_client.py` - Unit Tests (Mocked) - -Unit tests for `GrpcExternalPlugin` client using mocks. No real server is started. - -| Test | Description | -|------|-------------| -| **TestGrpcExternalPluginInit** | | -| `test_init_with_config` | Verifies plugin initializes with name and null channel/stub | -| `test_init_stores_config` | Verifies gRPC config (target address) is stored | -| **TestGrpcExternalPluginInitialize** | | -| `test_initialize_missing_grpc_config` | Raises `PluginError` when grpc config section is missing | -| `test_initialize_creates_channel` | Creates insecure gRPC channel and stub on init | -| `test_initialize_with_tls` | Creates secure channel when TLS config is present | -| `test_initialize_with_uds` | Uses `unix://` target format for Unix domain sockets | -| `test_initialize_config_retrieval_failure` | Raises `PluginError` when remote config not found | -| `test_initialize_connection_error` | Handles gRPC connection errors gracefully | -| **TestGrpcExternalPluginInvokeHook** | | -| `test_invoke_hook_success` | Successfully invokes hook and returns result | -| `test_invoke_hook_stub_not_initialized` | Raises error when stub not initialized | -| `test_invoke_hook_error_response` | Handles error responses from server | -| `test_invoke_hook_grpc_error` | Handles gRPC-level errors (network issues) | -| `test_invoke_hook_unregistered_hook_type` | Raises error for unknown hook types | -| `test_invoke_hook_updates_context` | Context state is updated from server response | -| **TestGrpcExternalPluginShutdown** | | -| `test_shutdown_closes_channel` | Channel is closed and references cleared | -| `test_shutdown_no_channel` | Safe to call when not connected | -| `test_shutdown_idempotent` | Multiple shutdown calls don't raise errors | -| **TestGrpcExternalPluginRetry** | | -| `test_get_plugin_config_with_retry_success` | Config retrieval succeeds on first attempt | -| `test_get_plugin_config_with_retry_eventual_success` | Retries and succeeds after failures | -| `test_get_plugin_config_with_retry_all_failures` | Raises after max retry attempts | - -### `test_client_integration.py` - Integration Tests (Real Server) - -Integration tests that spawn a real gRPC server subprocess and test actual communication. - -**Direct Plugin Tests:** - -| Test | Description | -|------|-------------| -| `test_grpc_client_invoke_hook` | Invokes `prompt_pre_fetch` hook over TCP, verifies word replacement ("crap" → "yikes") | -| `test_grpc_client_post_hook` | Invokes `prompt_post_fetch` hook, verifies message text transformation | -| `test_grpc_client_context_propagation` | Verifies request_id, server_id, user, tenant_id are passed through | -| `test_grpc_client_over_uds` | Tests gRPC communication over Unix domain socket (skipped on Windows) | - -**PluginManager Tests:** - -| Test | Description | -|------|-------------| -| `test_grpc_plugin_manager_invoke_hook` | Tests PluginManager loading and invoking hooks through gRPC external plugin | -| `test_grpc_plugin_manager_multiple_hooks` | Tests PluginManager invoking both pre-fetch and post-fetch hooks | -| `test_grpc_plugin_manager_context_persistence` | Tests context persistence across multiple PluginManager calls | - -### `test_grpc_models.py` - Model Tests - -Tests for gRPC-related Pydantic models (`GRPCClientConfig`, `GRPCServerConfig`, etc.). - -### `test_tls_utils.py` - TLS Utility Tests - -Tests for TLS certificate loading and credential creation utilities. - -### `server/test_server.py` - Server Unit Tests (Mocked) - -Unit tests for `GrpcPluginServicer` and `GrpcHealthServicer`. - -| Test | Description | -|------|-------------| -| **TestGrpcPluginServicerGetPluginConfig** | | -| `test_get_plugin_config_found` | Returns config when plugin exists | -| `test_get_plugin_config_not_found` | Returns `found=False` when plugin doesn't exist | -| **TestGrpcPluginServicerGetPluginConfigs** | | -| `test_get_plugin_configs_empty` | Returns empty list when no plugins | -| `test_get_plugin_configs_multiple` | Returns all plugin configs | -| **TestGrpcPluginServicerInvokeHook** | | -| `test_invoke_hook_success` | Returns successful result with continue_processing | -| `test_invoke_hook_with_error` | Returns PluginError details in response | -| `test_invoke_hook_with_context_update` | Includes updated context in response | -| `test_invoke_hook_unexpected_error` | Handles unexpected exceptions gracefully | -| **TestGrpcHealthServicer** | | -| `test_check_serving` | Returns SERVING when plugins loaded | -| `test_check_always_serving` | Returns SERVING even with no plugins | -| `test_check_with_service_name` | Handles specific service name requests | -| **TestGrpcPluginServicerEdgeCases** | | -| `test_invoke_hook_with_violation` | Handles results containing policy violations | -| `test_invoke_hook_with_modified_payload` | Handles results with transformed payloads | - -### `server/test_runtime.py` - Runtime Tests - -Tests for the gRPC server runtime entry point and configuration. - -## Running Tests - -```bash -# Run all gRPC tests -pytest tests/unit/cpex/framework/external/grpc/ -v - -# Run only unit tests (fast, no subprocess) -pytest tests/unit/cpex/framework/external/grpc/test_client.py -v - -# Run only integration tests (spawns real server) -pytest tests/unit/cpex/framework/external/grpc/test_client_integration.py -v -``` - -## Test Fixtures - -- `grpc_server_proc`: Starts gRPC server on random TCP port -- `grpc_server_proc_uds`: Starts gRPC server on Unix domain socket -- `mock_plugin_config`: Creates test PluginConfig with gRPC target -- `mock_plugin_config_uds`: Creates test PluginConfig with UDS path diff --git a/tests/unit/cpex/framework/external/grpc/__init__.py b/tests/unit/cpex/framework/external/grpc/__init__.py deleted file mode 100644 index f3c1745b..00000000 --- a/tests/unit/cpex/framework/external/grpc/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""gRPC external plugin tests.""" diff --git a/tests/unit/cpex/framework/external/grpc/proto/__init__.py b/tests/unit/cpex/framework/external/grpc/proto/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/cpex/framework/external/grpc/proto/test_plugin_service_pb2_grpc.py b/tests/unit/cpex/framework/external/grpc/proto/test_plugin_service_pb2_grpc.py deleted file mode 100644 index c89ad459..00000000 --- a/tests/unit/cpex/framework/external/grpc/proto/test_plugin_service_pb2_grpc.py +++ /dev/null @@ -1,132 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/proto/test_plugin_service_pb2_grpc.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Unit tests for generated gRPC plugin service stubs. -Covers base servicer unimplemented methods, stubs, and experimental API. -""" - -# Standard -from unittest.mock import MagicMock, patch - -# Third-Party -import pytest - -try: - import grpc - - from cpex.framework.external.grpc.proto import plugin_service_pb2, plugin_service_pb2_grpc - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -class TestPluginServiceServicerBase: - """Tests for the base PluginServiceServicer (unimplemented stubs).""" - - def test_get_plugin_config_raises_not_implemented(self): - servicer = plugin_service_pb2_grpc.PluginServiceServicer() - ctx = MagicMock() - with pytest.raises(NotImplementedError, match="Method not implemented!"): - servicer.GetPluginConfig(MagicMock(), ctx) - ctx.set_code.assert_called_once_with(grpc.StatusCode.UNIMPLEMENTED) - ctx.set_details.assert_called_once_with("Method not implemented!") - - def test_get_plugin_configs_raises_not_implemented(self): - servicer = plugin_service_pb2_grpc.PluginServiceServicer() - ctx = MagicMock() - with pytest.raises(NotImplementedError, match="Method not implemented!"): - servicer.GetPluginConfigs(MagicMock(), ctx) - ctx.set_code.assert_called_once_with(grpc.StatusCode.UNIMPLEMENTED) - ctx.set_details.assert_called_once_with("Method not implemented!") - - def test_invoke_hook_raises_not_implemented(self): - servicer = plugin_service_pb2_grpc.PluginServiceServicer() - ctx = MagicMock() - with pytest.raises(NotImplementedError, match="Method not implemented!"): - servicer.InvokeHook(MagicMock(), ctx) - ctx.set_code.assert_called_once_with(grpc.StatusCode.UNIMPLEMENTED) - ctx.set_details.assert_called_once_with("Method not implemented!") - - -class TestHealthServicerBase: - """Tests for the base HealthServicer (unimplemented stub).""" - - def test_check_raises_not_implemented(self): - servicer = plugin_service_pb2_grpc.HealthServicer() - ctx = MagicMock() - with pytest.raises(NotImplementedError, match="Method not implemented!"): - servicer.Check(MagicMock(), ctx) - ctx.set_code.assert_called_once_with(grpc.StatusCode.UNIMPLEMENTED) - ctx.set_details.assert_called_once_with("Method not implemented!") - - -class TestHealthStub: - """Tests for HealthStub initialization.""" - - def test_health_stub_init(self): - channel = MagicMock() - stub = plugin_service_pb2_grpc.HealthStub(channel) - assert stub.Check is not None - channel.unary_unary.assert_called_once_with( - "/cpex.Health/Check", - request_serializer=plugin_service_pb2.HealthCheckRequest.SerializeToString, - response_deserializer=plugin_service_pb2.HealthCheckResponse.FromString, - _registered_method=True, - ) - - -class TestPluginServiceExperimentalAPI: - """Tests for the experimental PluginService static methods.""" - - @patch("grpc.experimental.unary_unary") - def test_get_plugin_config(self, mock_unary): - mock_unary.return_value = MagicMock() - request = MagicMock() - result = plugin_service_pb2_grpc.PluginService.GetPluginConfig(request, "target:50051") - mock_unary.assert_called_once() - args = mock_unary.call_args - assert args[0][0] is request - assert args[0][1] == "target:50051" - assert args[0][2] == "/cpex.PluginService/GetPluginConfig" - assert result is mock_unary.return_value - - @patch("grpc.experimental.unary_unary") - def test_get_plugin_configs(self, mock_unary): - mock_unary.return_value = MagicMock() - request = MagicMock() - result = plugin_service_pb2_grpc.PluginService.GetPluginConfigs(request, "target:50051") - mock_unary.assert_called_once() - args = mock_unary.call_args - assert args[0][2] == "/cpex.PluginService/GetPluginConfigs" - assert result is mock_unary.return_value - - @patch("grpc.experimental.unary_unary") - def test_invoke_hook(self, mock_unary): - mock_unary.return_value = MagicMock() - request = MagicMock() - result = plugin_service_pb2_grpc.PluginService.InvokeHook(request, "target:50051") - mock_unary.assert_called_once() - args = mock_unary.call_args - assert args[0][2] == "/cpex.PluginService/InvokeHook" - assert result is mock_unary.return_value - - -class TestHealthExperimentalAPI: - """Tests for the experimental Health static method.""" - - @patch("grpc.experimental.unary_unary") - def test_check(self, mock_unary): - mock_unary.return_value = MagicMock() - request = MagicMock() - result = plugin_service_pb2_grpc.Health.Check(request, "target:50051") - mock_unary.assert_called_once() - args = mock_unary.call_args - assert args[0][0] is request - assert args[0][1] == "target:50051" - assert args[0][2] == "/cpex.Health/Check" - assert result is mock_unary.return_value diff --git a/tests/unit/cpex/framework/external/grpc/server/__init__.py b/tests/unit/cpex/framework/external/grpc/server/__init__.py deleted file mode 100644 index 22476112..00000000 --- a/tests/unit/cpex/framework/external/grpc/server/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""gRPC server tests.""" diff --git a/tests/unit/cpex/framework/external/grpc/server/test_runtime.py b/tests/unit/cpex/framework/external/grpc/server/test_runtime.py deleted file mode 100644 index f5b7223a..00000000 --- a/tests/unit/cpex/framework/external/grpc/server/test_runtime.py +++ /dev/null @@ -1,535 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/server/test_runtime.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for gRPC plugin server runtime. -Tests for GrpcPluginRuntime initialization, start, and stop. -""" - -# Standard -import asyncio -import os -import signal -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import GRPCServerConfig, GRPCServerTLSConfig - -# Check if grpc is available -try: - import grpc # noqa: F401 - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -class TestGrpcPluginRuntimeInit: - """Tests for GrpcPluginRuntime initialization.""" - - def test_init_default_config(self): - """Test runtime initialization with default config.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - # config_path can be None (will use default path later) - assert runtime._host_override is None - assert runtime._port_override is None - - def test_init_with_config_path(self): - """Test runtime initialization with custom config path.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime(config_path="/custom/config.yaml") - assert runtime._config_path == "/custom/config.yaml" - - def test_init_with_host_port_override(self): - """Test runtime initialization with host/port override.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime(host="127.0.0.1", port=50052) - assert runtime._host_override == "127.0.0.1" - assert runtime._port_override == 50052 - - -class TestGrpcPluginRuntimeGetServerConfig: - """Tests for GrpcPluginRuntime._get_server_config.""" - - def test_get_server_config_from_plugin_server(self): - """Test getting server config from plugin server.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - - mock_plugin_server = MagicMock() - mock_plugin_server.get_grpc_server_config = MagicMock( - return_value=GRPCServerConfig(host="192.168.1.1", port=50053) - ) - runtime._plugin_server = mock_plugin_server - - config = runtime._get_server_config() - assert config.host == "192.168.1.1" - assert config.port == 50053 - - def test_get_server_config_from_env(self): - """Test getting server config from environment variables.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - - mock_plugin_server = MagicMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=None) - runtime._plugin_server = mock_plugin_server - - env_vars = { - "PLUGINS_GRPC_SERVER_HOST": "10.0.0.1", - "PLUGINS_GRPC_SERVER_PORT": "50054", - } - with patch.dict(os.environ, env_vars, clear=True): - with patch.object(GRPCServerConfig, "from_env", return_value=GRPCServerConfig(host="10.0.0.1", port=50054)): - config = runtime._get_server_config() - assert config.host == "10.0.0.1" - assert config.port == 50054 - - def test_get_server_config_defaults(self): - """Test getting default server config when no config available.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - - mock_plugin_server = MagicMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=None) - runtime._plugin_server = mock_plugin_server - - with patch.dict(os.environ, {}, clear=True): - with patch.object(GRPCServerConfig, "from_env", return_value=None): - config = runtime._get_server_config() - # Should return default config - assert config.host == "127.0.0.1" - assert config.port == 50051 - - -class TestGrpcPluginRuntimeStart: - """Tests for GrpcPluginRuntime.start.""" - - @pytest.mark.asyncio - async def test_start_creates_server(self): - """Test start creates gRPC server.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - - mock_plugin_server = AsyncMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=GRPCServerConfig()) - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - mock_grpc_server = MagicMock() - mock_grpc_server.start = AsyncMock() - mock_grpc_server.add_insecure_port = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.ExternalPluginServer", - return_value=mock_plugin_server, - ): - with patch("grpc.aio.server", return_value=mock_grpc_server): - # Start in background and immediately trigger shutdown - runtime._shutdown_event.set() - await runtime.start() - - mock_grpc_server.add_insecure_port.assert_called_once() - mock_grpc_server.start.assert_called_once() - - @pytest.mark.asyncio - async def test_start_with_uds(self, tmp_path): - """Test start with Unix domain socket configuration.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - uds_path = str(tmp_path / "grpc.sock") - runtime = GrpcPluginRuntime() - - mock_plugin_server = AsyncMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=GRPCServerConfig(uds=uds_path)) - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - mock_grpc_server = MagicMock() - mock_grpc_server.start = AsyncMock() - mock_grpc_server.add_insecure_port = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.ExternalPluginServer", - return_value=mock_plugin_server, - ): - with patch("grpc.aio.server", return_value=mock_grpc_server): - runtime._shutdown_event.set() - await runtime.start() - - # Should bind to unix:// address - call_args = mock_grpc_server.add_insecure_port.call_args[0][0] - assert call_args.startswith("unix://") - assert uds_path in call_args - - @pytest.mark.asyncio - async def test_start_with_tls(self, tmp_path): - """Test start with TLS configuration.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - cert_file.write_bytes(b"CERT") - key_file.write_bytes(b"KEY") - - tls_config = GRPCServerTLSConfig( - certfile=str(cert_file), - keyfile=str(key_file), - client_auth="none", - ) - - runtime = GrpcPluginRuntime() - - mock_plugin_server = AsyncMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=GRPCServerConfig(tls=tls_config)) - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - mock_grpc_server = MagicMock() - mock_grpc_server.start = AsyncMock() - mock_grpc_server.add_secure_port = MagicMock() - - mock_credentials = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.ExternalPluginServer", - return_value=mock_plugin_server, - ): - with patch("grpc.aio.server", return_value=mock_grpc_server): - with patch( - "cpex.framework.external.grpc.server.runtime.create_server_credentials", - return_value=mock_credentials, - ): - runtime._shutdown_event.set() - await runtime.start() - - mock_grpc_server.add_secure_port.assert_called_once() - - -class TestGrpcPluginRuntimeStop: - """Tests for GrpcPluginRuntime.stop.""" - - @pytest.mark.asyncio - async def test_stop_graceful_shutdown(self): - """Test stop performs graceful shutdown.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - - mock_grpc_server = MagicMock() - mock_grpc_server.stop = MagicMock(return_value=AsyncMock()()) - mock_grpc_server.wait_for_termination = AsyncMock() - - mock_plugin_server = AsyncMock() - - runtime._server = mock_grpc_server - runtime._plugin_server = mock_plugin_server - - await runtime.stop() - - mock_grpc_server.stop.assert_called_once() - runtime._shutdown_event.is_set() - - @pytest.mark.asyncio - async def test_stop_no_server(self): - """Test stop handles case when server is None.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - # Server not started - - # Should not raise - await runtime.stop() - - -class TestGrpcPluginRuntimeIntegration: - """Integration tests for GrpcPluginRuntime.""" - - @pytest.mark.asyncio - async def test_full_lifecycle(self, tmp_path): - """Test full start/stop lifecycle.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - config_file = tmp_path / "config.yaml" - config_file.write_text(""" -plugins: [] -plugin_settings: - parallel_execution_within_band: false -""") - - runtime = GrpcPluginRuntime(config_path=str(config_file)) - - mock_plugin_server = AsyncMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=GRPCServerConfig()) - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - mock_grpc_server = MagicMock() - mock_grpc_server.start = AsyncMock() - mock_grpc_server.stop = MagicMock(return_value=AsyncMock()()) - mock_grpc_server.wait_for_termination = AsyncMock() - mock_grpc_server.add_insecure_port = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.ExternalPluginServer", - return_value=mock_plugin_server, - ): - with patch("grpc.aio.server", return_value=mock_grpc_server): - # Start and immediately stop - runtime._shutdown_event.set() - await runtime.start() - await runtime.stop() - - mock_grpc_server.start.assert_called_once() - mock_grpc_server.stop.assert_called_once() - - -class TestGrpcPluginRuntimeRequestShutdown: - """Tests for GrpcPluginRuntime.request_shutdown.""" - - def test_request_shutdown_sets_event(self): - """Test request_shutdown sets the shutdown event.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - assert not runtime._shutdown_event.is_set() - - runtime.request_shutdown() - assert runtime._shutdown_event.is_set() - - -class TestGrpcPluginRuntimeRunServer: - """Tests for the run_server function.""" - - @pytest.mark.asyncio - async def test_run_server_creates_runtime_and_starts(self): - """Test run_server creates a runtime and runs start/stop.""" - from cpex.framework.external.grpc.server.runtime import run_server - - mock_runtime = MagicMock() - mock_runtime.start = AsyncMock() - mock_runtime.stop = AsyncMock() - mock_runtime.request_shutdown = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.GrpcPluginRuntime", - return_value=mock_runtime, - ): - # Make start() immediately complete by making shutdown_event set - async def instant_start(): - return - - mock_runtime.start = AsyncMock(side_effect=instant_start) - await run_server(config_path="/test/config.yaml", host="localhost", port=50052) - - mock_runtime.start.assert_called_once() - mock_runtime.stop.assert_called_once() - - @pytest.mark.asyncio - async def test_run_server_stop_called_on_exception(self): - """Test run_server calls stop even when start raises.""" - from cpex.framework.external.grpc.server.runtime import run_server - - mock_runtime = MagicMock() - mock_runtime.start = AsyncMock(side_effect=RuntimeError("Start failed")) - mock_runtime.stop = AsyncMock() - mock_runtime.request_shutdown = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.GrpcPluginRuntime", - return_value=mock_runtime, - ): - with pytest.raises(RuntimeError, match="Start failed"): - await run_server() - - # stop() should still be called in finally block - mock_runtime.stop.assert_called_once() - - -class TestGrpcPluginRuntimeMain: - """Tests for the main() entry point.""" - - def test_main_keyboard_interrupt(self): - """Test main handles KeyboardInterrupt gracefully.""" - from cpex.framework.external.grpc.server.runtime import main - - def _raise_keyboard_interrupt(awaitable): - awaitable.close() - raise KeyboardInterrupt() - - with patch("sys.argv", ["runtime"]): - with patch( - "cpex.framework.external.grpc.server.runtime.asyncio.run", - side_effect=_raise_keyboard_interrupt, - ): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - - def test_main_exception_exits_with_error(self): - """Test main exits with code 1 on unexpected exception.""" - from cpex.framework.external.grpc.server.runtime import main - - def _raise_runtime_error(awaitable): - awaitable.close() - raise RuntimeError("Server failed") - - with patch("sys.argv", ["runtime"]): - with patch( - "cpex.framework.external.grpc.server.runtime.asyncio.run", - side_effect=_raise_runtime_error, - ): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 - - def test_main_parses_arguments(self): - """Test main correctly parses command line arguments.""" - from cpex.framework.external.grpc.server.runtime import main - - with patch( - "sys.argv", - [ - "runtime", - "--config", - "/custom/config.yaml", - "--host", - "127.0.0.1", - "--port", - "50052", - "--log-level", - "DEBUG", - ], - ): - captured = {} - - def _close_and_return(awaitable): - captured["awaitable"] = awaitable - awaitable.close() - return None - - with patch( - "cpex.framework.external.grpc.server.runtime.asyncio.run", - ) as mock_run: - mock_run.side_effect = _close_and_return - main() - mock_run.assert_called_once() - assert asyncio.iscoroutine(captured["awaitable"]) - - -class TestGrpcPluginRuntimeUdsChmod: - """Tests for UDS chmod behavior.""" - - @pytest.mark.asyncio - async def test_start_sets_socket_permissions(self, tmp_path): - """Test start sets permissions on UDS socket file.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - uds_path = str(tmp_path / "grpc.sock") - runtime = GrpcPluginRuntime() - - mock_plugin_server = AsyncMock() - mock_plugin_server.get_grpc_server_config = MagicMock(return_value=GRPCServerConfig(uds=uds_path)) - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - mock_grpc_server = MagicMock() - mock_grpc_server.start = AsyncMock() - mock_grpc_server.add_insecure_port = MagicMock() - - with patch( - "cpex.framework.external.grpc.server.runtime.ExternalPluginServer", - return_value=mock_plugin_server, - ): - with patch("grpc.aio.server", return_value=mock_grpc_server): - # Create the socket file to test chmod - with open(uds_path, "w") as f: - f.write("") - - runtime._shutdown_event.set() - await runtime.start() - - # Verify permissions were set to 0o600 - assert os.path.exists(uds_path) - mode = oct(os.stat(uds_path).st_mode & 0o777) - assert mode == "0o600" - - -class TestGrpcPluginRuntimeGetServerConfigNoPluginServer: - """Tests for _get_server_config when _plugin_server is None.""" - - def test_get_server_config_no_plugin_server_falls_to_env(self): - """Test _get_server_config falls through to env when _plugin_server is None.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - runtime._plugin_server = None - - with patch.object(GRPCServerConfig, "from_env", return_value=GRPCServerConfig(host="10.0.0.1", port=50055)): - config = runtime._get_server_config() - assert config.host == "10.0.0.1" - assert config.port == 50055 - - def test_get_server_config_no_plugin_server_falls_to_defaults(self): - """Test _get_server_config falls to defaults when _plugin_server is None and no env.""" - from cpex.framework.external.grpc.server.runtime import GrpcPluginRuntime - - runtime = GrpcPluginRuntime() - runtime._plugin_server = None - - with patch.object(GRPCServerConfig, "from_env", return_value=None): - config = runtime._get_server_config() - assert config.host == "127.0.0.1" - assert config.port == 50051 - - -class TestGrpcPluginRuntimeSignalHandler: - """Tests for the signal handler in run_server.""" - - @pytest.mark.asyncio - async def test_signal_handler_calls_request_shutdown(self): - """Test that the signal handler triggers request_shutdown.""" - from cpex.framework.external.grpc.server.runtime import run_server - - captured_handlers = {} - - mock_runtime = MagicMock() - mock_runtime.request_shutdown = MagicMock() - - async def mock_start(): - pass - - mock_runtime.start = AsyncMock(side_effect=mock_start) - mock_runtime.stop = AsyncMock() - - mock_loop = MagicMock() - - def capture_signal_handler(sig, handler): - captured_handlers[sig] = handler - - mock_loop.add_signal_handler = MagicMock(side_effect=capture_signal_handler) - - with patch( - "cpex.framework.external.grpc.server.runtime.GrpcPluginRuntime", - return_value=mock_runtime, - ): - with patch("asyncio.get_running_loop", return_value=mock_loop): - await run_server() - - # Signal handlers should have been registered - assert signal.SIGINT in captured_handlers - assert signal.SIGTERM in captured_handlers - - # Call the handler - should trigger request_shutdown - captured_handlers[signal.SIGINT]() - mock_runtime.request_shutdown.assert_called_once() diff --git a/tests/unit/cpex/framework/external/grpc/server/test_server.py b/tests/unit/cpex/framework/external/grpc/server/test_server.py deleted file mode 100644 index f5598365..00000000 --- a/tests/unit/cpex/framework/external/grpc/server/test_server.py +++ /dev/null @@ -1,525 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/server/test_server.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for gRPC plugin server. -Tests for GrpcPluginServicer and GrpcHealthServicer. -""" - -# Standard -from unittest.mock import AsyncMock, MagicMock - -import pytest - -# Third-Party -# First-Party -from cpex.framework.models import GlobalContext, PluginConfig, PluginContext - -try: - from google.protobuf import json_format - from google.protobuf.struct_pb2 import Struct - - from cpex.framework.external.grpc.proto import plugin_service_pb2 - from cpex.framework.external.grpc.server.server import GrpcHealthServicer, GrpcPluginServicer - - HAS_PROTOBUF = True -except ImportError: - HAS_PROTOBUF = False - grpc = None # type: ignore - json_format = None # type: ignore - Struct = None # type: ignore - -pytestmark = pytest.mark.skipif(not HAS_PROTOBUF, reason="google protobuf not installed") - - -@pytest.fixture -def mock_plugin_server(): - """Create a mock ExternalPluginServer for testing.""" - mock_server = AsyncMock() - mock_server.get_plugin_configs = AsyncMock(return_value=[]) - mock_server.get_plugin_config = AsyncMock(return_value=None) - mock_server.invoke_hook = AsyncMock(return_value={"continue_processing": True}) - return mock_server - - -@pytest.fixture -def servicer(mock_plugin_server): - """Create a GrpcPluginServicer for testing.""" - return GrpcPluginServicer(mock_plugin_server) - - -@pytest.fixture -def health_servicer(mock_plugin_server): - """Create a GrpcHealthServicer for testing.""" - return GrpcHealthServicer(mock_plugin_server) - - -class TestGrpcPluginServicerGetPluginConfig: - """Tests for GrpcPluginServicer.GetPluginConfig.""" - - @pytest.mark.asyncio - async def test_get_plugin_config_found(self, servicer, mock_plugin_server): - """Test GetPluginConfig returns config when found.""" - # Server expects dict from get_plugin_config - mock_config_dict = { - "name": "TestPlugin", - "kind": "test.plugin.TestPlugin", - "hooks": ["tool_pre_invoke"], - } - mock_plugin_server.get_plugin_config = AsyncMock(return_value=mock_config_dict) - - request = plugin_service_pb2.GetPluginConfigRequest(name="TestPlugin") - context = MagicMock() - - response = await servicer.GetPluginConfig(request, context) - - assert response.found is True - config_dict = json_format.MessageToDict(response.config) - assert config_dict["name"] == "TestPlugin" - - @pytest.mark.asyncio - async def test_get_plugin_config_not_found(self, servicer, mock_plugin_server): - """Test GetPluginConfig returns not found when plugin doesn't exist.""" - mock_plugin_server.get_plugin_config = AsyncMock(return_value=None) - - request = plugin_service_pb2.GetPluginConfigRequest(name="NonExistent") - context = MagicMock() - - response = await servicer.GetPluginConfig(request, context) - - assert response.found is False - - -class TestGrpcPluginServicerGetPluginConfigs: - """Tests for GrpcPluginServicer.GetPluginConfigs.""" - - @pytest.mark.asyncio - async def test_get_plugin_configs_empty(self, servicer, mock_plugin_server): - """Test GetPluginConfigs returns empty list when no plugins.""" - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - request = plugin_service_pb2.GetPluginConfigsRequest() - context = MagicMock() - - response = await servicer.GetPluginConfigs(request, context) - - assert len(response.configs) == 0 - - @pytest.mark.asyncio - async def test_get_plugin_configs_multiple(self, servicer, mock_plugin_server): - """Test GetPluginConfigs returns multiple configs.""" - # Server expects list of dicts from get_plugin_configs - mock_configs = [ - {"name": "Plugin1", "kind": "test.Plugin1", "hooks": ["tool_pre_invoke"]}, - {"name": "Plugin2", "kind": "test.Plugin2", "hooks": ["prompt_pre_fetch"]}, - ] - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=mock_configs) - - request = plugin_service_pb2.GetPluginConfigsRequest() - context = MagicMock() - - response = await servicer.GetPluginConfigs(request, context) - - assert len(response.configs) == 2 - - -class TestGrpcPluginServicerInvokeHook: - """Tests for GrpcPluginServicer.InvokeHook.""" - - @pytest.mark.asyncio - async def test_invoke_hook_success(self, servicer, mock_plugin_server): - """Test InvokeHook returns successful result.""" - # The server returns "result" key when the hook produces a result dict - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": { - "continue_processing": True, - "modified_payload": None, - } - } - ) - - # Build request - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - # Build context - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - - response = await servicer.InvokeHook(request, context) - - assert response.HasField("result") - result_dict = json_format.MessageToDict(response.result) - assert result_dict.get("continueProcessing") is True or result_dict.get("continue_processing") is True - - @pytest.mark.asyncio - async def test_invoke_hook_with_error(self, servicer, mock_plugin_server): - """Test InvokeHook handles plugin errors.""" - from cpex.framework.errors import PluginError - from cpex.framework.models import PluginErrorModel - - mock_plugin_server.invoke_hook = AsyncMock( - side_effect=PluginError( - error=PluginErrorModel( - message="Processing failed", - plugin_name="TestPlugin", - code="PROCESSING_ERROR", - ) - ) - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - - response = await servicer.InvokeHook(request, context) - - assert response.HasField("error") - assert response.error.message == "Processing failed" - assert response.error.plugin_name == "TestPlugin" - - @pytest.mark.asyncio - async def test_invoke_hook_with_context_update(self, servicer, mock_plugin_server): - """Test InvokeHook includes context updates in response.""" - # Return result with context - result_context = PluginContext( - global_context=GlobalContext(request_id="test", server_id="test"), - state={"updated_key": "updated_value"}, - ) - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "continue_processing": True, - "context": result_context, - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - - response = await servicer.InvokeHook(request, context) - - assert response.HasField("context") - - @pytest.mark.asyncio - async def test_invoke_hook_unexpected_error(self, servicer, mock_plugin_server): - """Test InvokeHook handles unexpected exceptions.""" - mock_plugin_server.invoke_hook = AsyncMock(side_effect=RuntimeError("Unexpected error")) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - - response = await servicer.InvokeHook(request, context) - - assert response.HasField("error") - assert "Unexpected error" in response.error.message - - -class TestGrpcHealthServicer: - """Tests for GrpcHealthServicer.""" - - @pytest.mark.asyncio - async def test_check_serving(self, health_servicer, mock_plugin_server): - """Test health check returns SERVING when plugins are loaded.""" - mock_plugin_server.get_plugin_configs = AsyncMock( - return_value=[ - PluginConfig(name="Plugin1", kind="test.Plugin1", hooks=[]), - ] - ) - - request = plugin_service_pb2.HealthCheckRequest() - context = MagicMock() - - response = await health_servicer.Check(request, context) - - assert response.status == plugin_service_pb2.HealthCheckResponse.SERVING - - @pytest.mark.asyncio - async def test_check_always_serving(self, health_servicer, mock_plugin_server): - """Test health check returns SERVING even when no plugins loaded. - - The current implementation always returns SERVING if the server is running. - This may be enhanced in the future to check plugin server health. - """ - mock_plugin_server.get_plugin_configs = AsyncMock(return_value=[]) - - request = plugin_service_pb2.HealthCheckRequest() - context = MagicMock() - - response = await health_servicer.Check(request, context) - - # Server always returns SERVING when running - assert response.status == plugin_service_pb2.HealthCheckResponse.SERVING - - @pytest.mark.asyncio - async def test_check_with_service_name(self, health_servicer, mock_plugin_server): - """Test health check with specific service name.""" - request = plugin_service_pb2.HealthCheckRequest(service="plugin_service") - context = MagicMock() - - response = await health_servicer.Check(request, context) - - # Server always returns SERVING when running - assert response.status == plugin_service_pb2.HealthCheckResponse.SERVING - - -class TestGrpcPluginServicerEdgeCases: - """Edge case tests for GrpcPluginServicer.""" - - @pytest.mark.asyncio - async def test_invoke_hook_with_violation(self, servicer, mock_plugin_server): - """Test InvokeHook handles results with violations.""" - # Server expects "result" key in the invoke_hook return value - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": { - "continue_processing": False, - "violation": { - "code": "BLOCKED", - "message": "Content blocked by policy", - }, - } - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - - response = await servicer.InvokeHook(request, context) - - assert response.HasField("result") - result_dict = json_format.MessageToDict(response.result) - # Check continue_processing is False (camelCase in proto) - assert result_dict.get("continueProcessing") is False or result_dict.get("continue_processing") is False - - @pytest.mark.asyncio - async def test_invoke_hook_with_modified_payload(self, servicer, mock_plugin_server): - """Test InvokeHook handles results with modified payload.""" - # Server expects "result" key in the invoke_hook return value - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": { - "continue_processing": True, - "modified_payload": {"name": "modified_tool", "args": {"modified": True}}, - } - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - - response = await servicer.InvokeHook(request, context) - - assert response.HasField("result") - result_dict = json_format.MessageToDict(response.result) - assert "modifiedPayload" in result_dict or "modified_payload" in result_dict - - -class TestGrpcPluginServicerExceptionHandling: - """Tests for exception handling in GrpcPluginServicer.""" - - @pytest.mark.asyncio - async def test_get_plugin_config_exception(self, servicer, mock_plugin_server): - """Test GetPluginConfig handles exceptions with gRPC error codes.""" - mock_plugin_server.get_plugin_config = AsyncMock(side_effect=RuntimeError("DB error")) - - request = plugin_service_pb2.GetPluginConfigRequest(name="TestPlugin") - context = MagicMock() - - response = await servicer.GetPluginConfig(request, context) - - assert response.found is False - context.set_code.assert_called_once() - context.set_details.assert_called_once() - - @pytest.mark.asyncio - async def test_get_plugin_configs_exception(self, servicer, mock_plugin_server): - """Test GetPluginConfigs handles exceptions with gRPC error codes.""" - mock_plugin_server.get_plugin_configs = AsyncMock(side_effect=RuntimeError("DB error")) - - request = plugin_service_pb2.GetPluginConfigsRequest() - context = MagicMock() - - response = await servicer.GetPluginConfigs(request, context) - - assert len(response.configs) == 0 - context.set_code.assert_called_once() - context.set_details.assert_called_once() - - @pytest.mark.asyncio - async def test_invoke_hook_with_error_dict(self, servicer, mock_plugin_server): - """Test InvokeHook handles error as raw dict (not model_dump).""" - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "error": { - "message": "Raw dict error", - "plugin_name": "TestPlugin", - "code": "RAW_ERROR", - "mcp_error_code": -32600, - "details": {"extra": "info"}, - } - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - response = await servicer.InvokeHook(request, context) - - assert response.HasField("error") - assert response.error.message == "Raw dict error" - - @pytest.mark.asyncio - async def test_invoke_hook_with_error_model(self, servicer, mock_plugin_server): - """Test InvokeHook handles error as Pydantic model with model_dump.""" - from cpex.framework.models import PluginErrorModel - - error_model = PluginErrorModel( - message="Model error", - plugin_name="TestPlugin", - code="MODEL_ERROR", - ) - mock_plugin_server.invoke_hook = AsyncMock(return_value={"error": error_model}) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - response = await servicer.InvokeHook(request, context) - - assert response.HasField("error") - assert response.error.message == "Model error" - - @pytest.mark.asyncio - async def test_invoke_hook_with_dict_context(self, servicer, mock_plugin_server): - """Test InvokeHook handles context as dict (not PluginContext).""" - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": {"continue_processing": True}, - "context": { - "global_context": {"request_id": "req-1", "server_id": "srv-1"}, - "state": {"updated": True}, - }, - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - context = MagicMock() - response = await servicer.InvokeHook(request, context) - - assert response.HasField("context") - - @pytest.mark.asyncio - async def test_dict_to_plugin_error(self, servicer): - """Test _dict_to_plugin_error converts dict to PluginError proto.""" - error_dict = { - "message": "Test error", - "plugin_name": "TestPlugin", - "code": "TEST_ERROR", - "mcp_error_code": -32603, - "details": {"extra": "detail"}, - } - error_proto = servicer._dict_to_plugin_error(error_dict) - - assert error_proto.message == "Test error" - assert error_proto.plugin_name == "TestPlugin" - assert error_proto.code == "TEST_ERROR" - assert error_proto.mcp_error_code == -32603 - details = json_format.MessageToDict(error_proto.details) - assert details["extra"] == "detail" - - @pytest.mark.asyncio - async def test_dict_to_plugin_error_minimal(self, servicer): - """Test _dict_to_plugin_error with minimal dict.""" - error_dict = {} - error_proto = servicer._dict_to_plugin_error(error_dict) - - assert error_proto.message == "Unknown error" - assert error_proto.plugin_name == "unknown" - assert error_proto.code == "" - assert error_proto.mcp_error_code == -32603 diff --git a/tests/unit/cpex/framework/external/grpc/test_client.py b/tests/unit/cpex/framework/external/grpc/test_client.py deleted file mode 100644 index ffe038c8..00000000 --- a/tests/unit/cpex/framework/external/grpc/test_client.py +++ /dev/null @@ -1,562 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/test_client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for gRPC external plugin client. -Tests for GrpcExternalPlugin initialization, hook invocation, and error handling. -""" - -# Standard -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import pytest -from pydantic import ValidationError - -# First-Party -from cpex.framework import PluginError, ToolPreInvokePayload -from cpex.framework.models import ( - GlobalContext, - GRPCClientConfig, - PluginConfig, - PluginContext, -) - -# Check if grpc is available -try: - import grpc - from google.protobuf import json_format - from google.protobuf.struct_pb2 import Struct - - from cpex.framework.external.grpc.client import GrpcExternalPlugin - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - grpc = None # type: ignore - json_format = None # type: ignore - Struct = None # type: ignore - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -@pytest.fixture -def mock_plugin_config(): - """Create a mock plugin config for testing.""" - return PluginConfig( - name="TestGrpcPlugin", - kind="external", - hooks=["tool_pre_invoke"], - grpc=GRPCClientConfig(target="localhost:50051"), - ) - - -@pytest.fixture -def mock_plugin_config_uds(tmp_path): - """Create a mock plugin config with UDS for testing.""" - uds_path = str(tmp_path / "test.sock") - return PluginConfig( - name="TestGrpcUdsPlugin", - kind="external", - hooks=["tool_pre_invoke"], - grpc=GRPCClientConfig(uds=uds_path), - ) - - -class TestGrpcExternalPluginInit: - """Tests for GrpcExternalPlugin initialization.""" - - def test_init_with_config(self, mock_plugin_config): - """Test plugin initialization with valid config.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - assert plugin.name == "TestGrpcPlugin" - assert plugin._channel is None - assert plugin._stub is None - - def test_init_stores_config(self, mock_plugin_config): - """Test plugin stores configuration.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - assert plugin._config.grpc is not None - assert plugin._config.grpc.target == "localhost:50051" - - -class TestGrpcExternalPluginInitialize: - """Tests for GrpcExternalPlugin.initialize().""" - - def test_initialize_missing_grpc_config(self): - """Test PluginConfig validation rejects external plugin without transport config.""" - with pytest.raises(ValidationError, match="External plugin.*must have"): - PluginConfig( - name="TestPlugin", - kind="external", - hooks=["tool_pre_invoke"], - ) - - @pytest.mark.asyncio - async def test_initialize_creates_channel(self, mock_plugin_config): - """Test initialize creates gRPC channel.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - - mock_channel = AsyncMock() - mock_stub = MagicMock() - - # Mock the remote config response - must include grpc section for external plugins - mock_response = MagicMock() - mock_response.found = True - config_struct = Struct() - json_format.ParseDict( - { - "name": "TestGrpcPlugin", - "kind": "test.plugin.TestPlugin", # Non-external kind to avoid validation - "hooks": ["tool_pre_invoke"], - }, - config_struct, - ) - mock_response.config = config_struct - mock_stub.GetPluginConfig = AsyncMock(return_value=mock_response) - - with patch( - "cpex.framework.external.grpc.client.create_insecure_channel", - return_value=mock_channel, - ): - with patch( - "cpex.framework.external.grpc.client.plugin_service_pb2_grpc.PluginServiceStub", - return_value=mock_stub, - ): - await plugin.initialize() - - assert plugin._channel is mock_channel - assert plugin._stub is mock_stub - - @pytest.mark.asyncio - async def test_initialize_with_tls(self, mock_plugin_config): - """Test initialize creates secure channel when TLS is configured.""" - from cpex.framework.models import GRPCClientTLSConfig - - mock_plugin_config.grpc.tls = GRPCClientTLSConfig(verify=True) - plugin = GrpcExternalPlugin(mock_plugin_config) - - mock_channel = AsyncMock() - mock_stub = MagicMock() - - mock_response = MagicMock() - mock_response.found = True - config_struct = Struct() - json_format.ParseDict( - {"name": "TestGrpcPlugin", "kind": "test.plugin.TestPlugin", "hooks": ["tool_pre_invoke"]}, config_struct - ) - mock_response.config = config_struct - mock_stub.GetPluginConfig = AsyncMock(return_value=mock_response) - - with patch( - "cpex.framework.external.grpc.client.create_secure_channel", - return_value=mock_channel, - ) as mock_create_secure: - with patch( - "cpex.framework.external.grpc.client.plugin_service_pb2_grpc.PluginServiceStub", - return_value=mock_stub, - ): - await plugin.initialize() - - mock_create_secure.assert_called_once() - - @pytest.mark.asyncio - async def test_initialize_with_uds(self, mock_plugin_config_uds): - """Test initialize with Unix domain socket.""" - plugin = GrpcExternalPlugin(mock_plugin_config_uds) - - mock_channel = AsyncMock() - mock_stub = MagicMock() - - mock_response = MagicMock() - mock_response.found = True - config_struct = Struct() - json_format.ParseDict( - {"name": "TestGrpcUdsPlugin", "kind": "test.plugin.TestPlugin", "hooks": ["tool_pre_invoke"]}, config_struct - ) - mock_response.config = config_struct - mock_stub.GetPluginConfig = AsyncMock(return_value=mock_response) - - with patch( - "cpex.framework.external.grpc.client.create_insecure_channel", - return_value=mock_channel, - ) as mock_create_insecure: - with patch( - "cpex.framework.external.grpc.client.plugin_service_pb2_grpc.PluginServiceStub", - return_value=mock_stub, - ): - await plugin.initialize() - - # Should use insecure channel for UDS (TLS not supported) - mock_create_insecure.assert_called_once() - # Target should be in unix:// format - call_args = mock_create_insecure.call_args[0][0] - assert call_args.startswith("unix://") - - @pytest.mark.asyncio - async def test_initialize_config_retrieval_failure(self, mock_plugin_config): - """Test initialize raises PluginError when config retrieval fails.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - - mock_channel = AsyncMock() - mock_stub = MagicMock() - - # Mock config not found - mock_response = MagicMock() - mock_response.found = False - mock_stub.GetPluginConfig = AsyncMock(return_value=mock_response) - - with patch( - "cpex.framework.external.grpc.client.create_insecure_channel", - return_value=mock_channel, - ): - with patch( - "cpex.framework.external.grpc.client.plugin_service_pb2_grpc.PluginServiceStub", - return_value=mock_stub, - ): - with pytest.raises(PluginError, match="Unable to retrieve configuration"): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_connection_error(self, mock_plugin_config): - """Test initialize handles connection errors.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - - mock_channel = AsyncMock() - mock_stub = MagicMock() - mock_stub.GetPluginConfig = AsyncMock(side_effect=grpc.RpcError()) - - with patch( - "cpex.framework.external.grpc.client.create_insecure_channel", - return_value=mock_channel, - ): - with patch( - "cpex.framework.external.grpc.client.plugin_service_pb2_grpc.PluginServiceStub", - return_value=mock_stub, - ): - with patch("asyncio.sleep", new_callable=AsyncMock): - with pytest.raises(PluginError, match="connection failed"): - await plugin.initialize() - - -class TestGrpcExternalPluginInvokeHook: - """Tests for GrpcExternalPlugin.invoke_hook().""" - - @pytest.fixture - def initialized_plugin(self, mock_plugin_config): - """Create an initialized plugin for testing.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - plugin._channel = AsyncMock() - plugin._stub = MagicMock() - return plugin - - @pytest.mark.asyncio - async def test_invoke_hook_success(self, initialized_plugin): - """Test successful hook invocation.""" - # Create mock response - mock_response = MagicMock() - mock_response.HasField = MagicMock(side_effect=lambda x: x == "result") - result_struct = Struct() - json_format.ParseDict({"continue_processing": True}, result_struct) - mock_response.result = result_struct - mock_response.error = MagicMock() - mock_response.error.message = "" - - initialized_plugin._stub.InvokeHook = AsyncMock(return_value=mock_response) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={"arg1": "value1"}) - - result = await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - assert result is not None - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_invoke_hook_stub_not_initialized(self, mock_plugin_config): - """Test invoke_hook raises error when stub not initialized.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - # Don't initialize - stub should be None - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="stub not initialized"): - await plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_error_response(self, initialized_plugin): - """Test invoke_hook handles error response from server.""" - mock_response = MagicMock() - mock_response.HasField = MagicMock(side_effect=lambda x: x == "error") - mock_response.error = MagicMock() - mock_response.error.message = "Plugin processing failed" - mock_response.error.plugin_name = "TestGrpcPlugin" - mock_response.error.code = "PROCESSING_ERROR" - mock_response.error.mcp_error_code = -32603 # Valid integer error code - mock_response.error.HasField = MagicMock(return_value=False) - - initialized_plugin._stub.InvokeHook = AsyncMock(return_value=mock_response) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="Plugin processing failed"): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_grpc_error(self, initialized_plugin): - """Test invoke_hook handles gRPC errors.""" - initialized_plugin._stub.InvokeHook = AsyncMock(side_effect=grpc.RpcError()) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_unregistered_hook_type(self, initialized_plugin): - """Test invoke_hook raises error for unregistered hook type.""" - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="not registered"): - await initialized_plugin.invoke_hook("invalid_hook_type", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_updates_context(self, initialized_plugin): - """Test invoke_hook updates context from response.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - mock_response = MagicMock() - - # Set up HasField to return True for both result and context - def has_field(name): - return name in ["result", "context"] - - mock_response.HasField = MagicMock(side_effect=has_field) - - # Set up result - result_struct = Struct() - json_format.ParseDict({"continue_processing": True}, result_struct) - mock_response.result = result_struct - - # Set up context with updated state - mock_context = plugin_service_pb2.PluginContext() - state_struct = Struct() - json_format.ParseDict({"key": "value"}, state_struct) - mock_context.state.CopyFrom(state_struct) - mock_response.context = mock_context - - # Error should not have message - mock_response.error = MagicMock() - mock_response.error.message = "" - - initialized_plugin._stub.InvokeHook = AsyncMock(return_value=mock_response) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - # Context should be updated with state from response - assert context.state.get("key") == "value" - - -class TestGrpcExternalPluginShutdown: - """Tests for GrpcExternalPlugin.shutdown().""" - - @pytest.mark.asyncio - async def test_shutdown_closes_channel(self, mock_plugin_config): - """Test shutdown closes the gRPC channel.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - mock_channel = AsyncMock() - plugin._channel = mock_channel - plugin._stub = MagicMock() - - await plugin.shutdown() - - mock_channel.close.assert_called_once() - assert plugin._channel is None - assert plugin._stub is None - - @pytest.mark.asyncio - async def test_shutdown_no_channel(self, mock_plugin_config): - """Test shutdown handles case when channel is None.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - # Don't set channel - should be None - - # Should not raise - await plugin.shutdown() - - @pytest.mark.asyncio - async def test_shutdown_idempotent(self, mock_plugin_config): - """Test shutdown can be called multiple times safely.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - mock_channel = AsyncMock() - plugin._channel = mock_channel - plugin._stub = MagicMock() - - await plugin.shutdown() - await plugin.shutdown() # Second call should not raise - - # close should only be called once - mock_channel.close.assert_called_once() - - -class TestGrpcExternalPluginRetry: - """Tests for retry logic in GrpcExternalPlugin.""" - - @pytest.mark.asyncio - async def test_get_plugin_config_with_retry_success(self, mock_plugin_config): - """Test retry logic succeeds on first attempt.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - mock_stub = MagicMock() - plugin._stub = mock_stub - - mock_response = MagicMock() - mock_response.found = True - config_struct = Struct() - # Use non-external kind to avoid validation requiring grpc/mcp/unix_socket section - json_format.ParseDict({"name": "TestPlugin", "kind": "test.plugin.TestPlugin", "hooks": []}, config_struct) - mock_response.config = config_struct - - mock_stub.GetPluginConfig = AsyncMock(return_value=mock_response) - - result = await plugin._get_plugin_config_with_retry(max_retries=3) - - assert result is not None - assert mock_stub.GetPluginConfig.call_count == 1 - - @pytest.mark.asyncio - async def test_get_plugin_config_with_retry_eventual_success(self, mock_plugin_config): - """Test retry logic succeeds after failures.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - mock_stub = MagicMock() - plugin._stub = mock_stub - - mock_response = MagicMock() - mock_response.found = True - config_struct = Struct() - # Use non-external kind to avoid validation requiring grpc/mcp/unix_socket section - json_format.ParseDict({"name": "TestPlugin", "kind": "test.plugin.TestPlugin", "hooks": []}, config_struct) - mock_response.config = config_struct - - # Fail twice, then succeed - mock_stub.GetPluginConfig = AsyncMock(side_effect=[grpc.RpcError(), grpc.RpcError(), mock_response]) - - with patch("asyncio.sleep", new_callable=AsyncMock): - result = await plugin._get_plugin_config_with_retry(max_retries=3, base_delay=0.01) - - assert result is not None - assert mock_stub.GetPluginConfig.call_count == 3 - - @pytest.mark.asyncio - async def test_get_plugin_config_with_retry_all_failures(self, mock_plugin_config): - """Test retry logic raises after all attempts fail.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - mock_stub = MagicMock() - plugin._stub = mock_stub - - mock_stub.GetPluginConfig = AsyncMock(side_effect=grpc.RpcError()) - - with patch("asyncio.sleep", new_callable=AsyncMock): - with pytest.raises(PluginError, match="connection failed after 3 attempts"): - await plugin._get_plugin_config_with_retry(max_retries=3, base_delay=0.01) - - assert mock_stub.GetPluginConfig.call_count == 3 - - -class TestGrpcExternalPluginInitializeGenericError: - """Tests for generic (non-gRPC) exception paths in initialize().""" - - @pytest.mark.asyncio - async def test_initialize_generic_exception_wraps_in_plugin_error(self, mock_plugin_config): - """Test initialize wraps non-gRPC exceptions in PluginError.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - - with patch( - "cpex.framework.external.grpc.client.create_insecure_channel", - side_effect=ValueError("bad channel config"), - ): - with pytest.raises(PluginError): - await plugin.initialize() - - -class TestGrpcExternalPluginGetConfigStubNone: - """Tests for _get_plugin_config when stub is None.""" - - @pytest.mark.asyncio - async def test_get_plugin_config_no_stub_raises(self, mock_plugin_config): - """Test _get_plugin_config raises PluginError when stub is None.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - # stub is None by default - - with pytest.raises(PluginError, match="stub not initialized"): - await plugin._get_plugin_config() - - -class TestGrpcExternalPluginInvokeHookEdgeCases: - """Tests for edge cases in invoke_hook().""" - - @pytest.fixture - def initialized_plugin(self, mock_plugin_config): - """Create an initialized plugin for testing.""" - plugin = GrpcExternalPlugin(mock_plugin_config) - plugin._channel = AsyncMock() - plugin._stub = MagicMock() - return plugin - - @pytest.mark.asyncio - async def test_invoke_hook_error_with_details(self, initialized_plugin): - """Test invoke_hook handles error response that has details field.""" - mock_response = MagicMock() - mock_response.HasField = MagicMock(side_effect=lambda x: x == "error") - mock_response.error = MagicMock() - mock_response.error.message = "Plugin failed" - mock_response.error.plugin_name = "TestGrpcPlugin" - mock_response.error.code = "ERR" - mock_response.error.mcp_error_code = -32603 - - details_struct = Struct() - json_format.ParseDict({"detail_key": "detail_value"}, details_struct) - mock_response.error.details = details_struct - mock_response.error.HasField = MagicMock(return_value=True) - - initialized_plugin._stub.InvokeHook = AsyncMock(return_value=mock_response) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="Plugin failed"): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_no_result_raises(self, initialized_plugin): - """Test invoke_hook raises when response has no error and no result.""" - mock_response = MagicMock() - mock_response.HasField = MagicMock(return_value=False) - mock_response.error = MagicMock() - mock_response.error.message = "" - - initialized_plugin._stub.InvokeHook = AsyncMock(return_value=mock_response) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="invalid response"): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_generic_exception(self, initialized_plugin): - """Test invoke_hook wraps generic exceptions in PluginError.""" - initialized_plugin._stub.InvokeHook = AsyncMock(side_effect=TypeError("unexpected")) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) diff --git a/tests/unit/cpex/framework/external/grpc/test_client_integration.py b/tests/unit/cpex/framework/external/grpc/test_client_integration.py deleted file mode 100644 index 5e232e15..00000000 --- a/tests/unit/cpex/framework/external/grpc/test_client_integration.py +++ /dev/null @@ -1,410 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/test_client_integration.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Integration tests for gRPC external plugin client. -These tests spawn a real gRPC server subprocess and test actual communication. -""" - -# Standard -import os -import socket -import subprocess -import sys -import time -from pathlib import Path - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - ConfigLoader, - GlobalContext, - PluginContext, - PluginLoader, - PluginManager, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, -) -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, Role, TextContent - -# Check if grpc is available -try: - import grpc # noqa: F401 - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -def _get_free_port() -> int: - """Get an available TCP port for testing.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def _wait_for_port(host: str, port: int, timeout: float = 15.0, proc: subprocess.Popen | None = None) -> None: - """Wait until a TCP port is accepting connections.""" - start = time.time() - while time.time() - start < timeout: - if proc and proc.poll() is not None: - output = "" - if proc.stdout: - output = proc.stdout.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Server exited before port opened. Output:\n{output}") - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(0.5) - if sock.connect_ex((host, port)) == 0: - return - time.sleep(0.1) - raise RuntimeError(f"Timed out waiting for {host}:{port}") - - -@pytest.fixture -def grpc_server_proc(): - """Start a gRPC plugin server subprocess.""" - current_env = os.environ.copy() - port = _get_free_port() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "grpc" - current_env["PLUGINS_GRPC_SERVER_HOST"] = "127.0.0.1" - current_env["PLUGINS_GRPC_SERVER_PORT"] = str(port) - - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/grpc/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_port("127.0.0.1", port, proc=server_proc) - yield server_proc, port - server_proc.terminate() - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait(timeout=3) - - -@pytest.mark.asyncio -async def test_grpc_client_invoke_hook(grpc_server_proc): - """Test gRPC client can invoke hooks on a real server.""" - server_proc, port = grpc_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml") - config.plugins[0].grpc.target = f"127.0.0.1:{port}" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - # Test prompt_pre_fetch hook - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - - # The ReplaceBadWordsPlugin replaces "crap" -> "crud" -> "yikes" - assert result.modified_payload.args["user"] == "What a yikesshow!" - - # Verify plugin config was retrieved from server - assert plugin.config.name == "ReplaceBadWordsPlugin" - assert plugin.config.description == "A plugin for finding and replacing words." - assert plugin.config.kind == "external" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.asyncio -async def test_grpc_client_post_hook(grpc_server_proc): - """Test gRPC client can invoke post-fetch hooks.""" - server_proc, port = grpc_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml") - config.plugins[0].grpc.target = f"127.0.0.1:{port}" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - # Test prompt_post_fetch hook - message = Message(content=TextContent(type="text", text="What the crud?"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result = await plugin.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload_result, context) - - assert len(result.modified_payload.result.messages) == 1 - # "crud" -> "yikes" - assert result.modified_payload.result.messages[0].content.text == "What the yikes?" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.asyncio -async def test_grpc_client_context_propagation(grpc_server_proc): - """Test that context is properly propagated through gRPC calls.""" - server_proc, port = grpc_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml") - config.plugins[0].grpc.target = f"127.0.0.1:{port}" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - # Create context with initial state - global_context = GlobalContext( - request_id="test-req-123", - server_id="test-server", - user="test-user", - tenant_id="test-tenant", - ) - context = PluginContext(global_context=global_context) - - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "Hello!"}) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - - # Verify the call succeeded - assert result.continue_processing is True - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.fixture -def grpc_server_proc_uds(tmp_path): - """Start a gRPC plugin server subprocess using Unix domain socket.""" - import uuid - - # Use /tmp directly to keep socket path short (macOS has ~104 char limit) - short_id = uuid.uuid4().hex[:8] - uds_path = f"/tmp/grpc-test-{short_id}.sock" - - current_env = os.environ.copy() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "grpc" - current_env["PLUGINS_GRPC_SERVER_UDS"] = uds_path - - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/grpc/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - # Wait for socket file to be created - _wait_for_socket(uds_path, proc=server_proc) - yield server_proc, uds_path - server_proc.terminate() - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait(timeout=3) - finally: - if os.path.exists(uds_path): - os.unlink(uds_path) - - -def _wait_for_socket(path: str, timeout: float = 15.0, proc: subprocess.Popen | None = None) -> None: - """Wait until a Unix domain socket path exists.""" - import stat - - start = time.time() - while time.time() - start < timeout: - if proc and proc.poll() is not None: - output = "" - if proc.stdout: - output = proc.stdout.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Server exited before socket created. Output:\n{output}") - try: - if os.path.exists(path) and stat.S_ISSOCK(os.stat(path).st_mode): - return - except FileNotFoundError: - pass - time.sleep(0.1) - raise RuntimeError(f"Timed out waiting for socket: {path}") - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_grpc_client_over_uds(grpc_server_proc_uds): - """Test gRPC client can communicate over Unix domain socket.""" - server_proc, uds_path = grpc_server_proc_uds - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin.yaml") - # Switch from TCP to UDS - config.plugins[0].grpc.target = None - config.plugins[0].grpc.uds = uds_path - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - - assert result.modified_payload.args["user"] == "What a yikesshow!" - finally: - await plugin.shutdown() - await loader.shutdown() - - -# ============================================================================= -# PluginManager Integration Tests -# ============================================================================= - - -@pytest.fixture -def grpc_server_proc_for_manager(tmp_path): - """Start a gRPC plugin server and return a matching PluginManager config file.""" - current_env = os.environ.copy() - port = _get_free_port() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "grpc" - current_env["PLUGINS_GRPC_SERVER_HOST"] = "127.0.0.1" - current_env["PLUGINS_GRPC_SERVER_PORT"] = str(port) - - template_config = Path("tests/unit/cpex/fixtures/configs/valid_grpc_external_plugin_manager.yaml") - dynamic_config = tmp_path / "valid_grpc_external_plugin_manager.dynamic.yaml" - dynamic_config.write_text( - template_config.read_text(encoding="utf-8").replace("127.0.0.1:50151", f"127.0.0.1:{port}"), - encoding="utf-8", - ) - - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/grpc/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_port("127.0.0.1", port, proc=server_proc) - yield server_proc, str(dynamic_config) - server_proc.terminate() - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait(timeout=3) - - -@pytest.mark.asyncio -async def test_grpc_plugin_manager_invoke_hook(grpc_server_proc_for_manager): - """Test PluginManager can invoke hooks through gRPC external plugin.""" - server_proc, config_path = grpc_server_proc_for_manager - assert not server_proc.poll(), "Server failed to start" - - # Reset PluginManager singleton state - PluginManager.reset() - - plugin_manager = PluginManager(config=config_path) - - try: - await plugin_manager.initialize() - - # Verify plugin was loaded - assert plugin_manager.plugin_count == 1 - - # Test prompt_pre_fetch hook through PluginManager - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - global_context = GlobalContext(request_id="test-1", server_id="test-server") - - result, contexts = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH.value, - payload, - global_context, - ) - - # Verify the transformation happened - assert result.modified_payload.args["user"] == "What a yikesshow!" - assert result.continue_processing is True - - finally: - await plugin_manager.shutdown() - - -@pytest.mark.asyncio -async def test_grpc_plugin_manager_multiple_hooks(grpc_server_proc_for_manager): - """Test PluginManager can invoke multiple hook types through gRPC.""" - server_proc, config_path = grpc_server_proc_for_manager - assert not server_proc.poll(), "Server failed to start" - - PluginManager.reset() - plugin_manager = PluginManager(config=config_path) - - try: - await plugin_manager.initialize() - - global_context = GlobalContext(request_id="test-1", server_id="test-server") - - # Test prompt_pre_fetch - pre_payload = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "This is crap!"}) - result, _ = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH.value, - pre_payload, - global_context, - ) - assert result.modified_payload.args["user"] == "This is yikes!" - - # Test prompt_post_fetch - message = Message(content=TextContent(type="text", text="What crud!"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - post_payload = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result, _ = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_POST_FETCH.value, - post_payload, - global_context, - ) - assert result.modified_payload.result.messages[0].content.text == "What yikes!" - - finally: - await plugin_manager.shutdown() - - -@pytest.mark.asyncio -async def test_grpc_plugin_manager_context_persistence(grpc_server_proc_for_manager): - """Test that context is maintained across multiple PluginManager calls.""" - server_proc, config_path = grpc_server_proc_for_manager - assert not server_proc.poll(), "Server failed to start" - - PluginManager.reset() - plugin_manager = PluginManager(config=config_path) - - try: - await plugin_manager.initialize() - - global_context = GlobalContext( - request_id="ctx-test-123", - server_id="test-server", - user="test-user", - tenant_id="test-tenant", - ) - - # Make multiple calls and verify context flows through - for i in range(3): - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"user": f"Test crap {i}"}) - result, contexts = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH.value, - payload, - global_context, - ) - assert result.modified_payload.args["user"] == f"Test yikes {i}" - assert result.continue_processing is True - - finally: - await plugin_manager.shutdown() diff --git a/tests/unit/cpex/framework/external/grpc/test_grpc_models.py b/tests/unit/cpex/framework/external/grpc/test_grpc_models.py deleted file mode 100644 index 93e9f127..00000000 --- a/tests/unit/cpex/framework/external/grpc/test_grpc_models.py +++ /dev/null @@ -1,373 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/test_grpc_models.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for gRPC configuration models. -Tests for GRPCClientConfig, GRPCServerConfig, and related TLS configurations. -""" - -# Standard -import os -from unittest.mock import patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import ( - GRPCClientConfig, - GRPCClientTLSConfig, - GRPCServerConfig, - GRPCServerTLSConfig, -) - - -class TestGRPCClientTLSConfig: - """Tests for GRPCClientTLSConfig model.""" - - def test_default_values(self): - """Test default TLS configuration values.""" - config = GRPCClientTLSConfig() - assert config.verify is True - - def test_verify_disabled(self): - """Test TLS configuration with verify disabled.""" - config = GRPCClientTLSConfig(verify=False) - assert config.verify is False - - def test_with_certificates(self, tmp_path): - """Test TLS configuration with certificate paths.""" - ca_file = tmp_path / "ca.pem" - cert_file = tmp_path / "client.pem" - key_file = tmp_path / "client-key.pem" - ca_file.touch() - cert_file.touch() - key_file.touch() - - config = GRPCClientTLSConfig( - ca_bundle=str(ca_file), - certfile=str(cert_file), - keyfile=str(key_file), - verify=True, - ) - assert config.ca_bundle == str(ca_file) - assert config.certfile == str(cert_file) - assert config.keyfile == str(key_file) - - def test_from_env_empty(self): - """Test from_env returns None when no env vars set.""" - with patch.dict(os.environ, {}, clear=True): - result = GRPCClientTLSConfig.from_env() - assert result is None - - def test_from_env_with_values(self, tmp_path): - """Test from_env with environment variables.""" - ca_file = tmp_path / "ca.pem" - cert_file = tmp_path / "client.pem" - key_file = tmp_path / "client-key.pem" - ca_file.touch() - cert_file.touch() - key_file.touch() - - env_vars = { - "PLUGINS_GRPC_CLIENT_MTLS_CA_BUNDLE": str(ca_file), - "PLUGINS_GRPC_CLIENT_MTLS_CERTFILE": str(cert_file), - "PLUGINS_GRPC_CLIENT_MTLS_KEYFILE": str(key_file), - "PLUGINS_GRPC_CLIENT_MTLS_VERIFY": "false", - } - with patch.dict(os.environ, env_vars, clear=True): - result = GRPCClientTLSConfig.from_env() - assert result is not None - assert result.ca_bundle == str(ca_file) - assert result.certfile == str(cert_file) - assert result.keyfile == str(key_file) - assert result.verify is False - - -class TestGRPCServerTLSConfig: - """Tests for GRPCServerTLSConfig model.""" - - def test_default_client_auth(self): - """Test default client_auth is 'require'.""" - config = GRPCServerTLSConfig() - assert config.client_auth == "require" - - def test_valid_client_auth_values(self): - """Test valid client_auth values.""" - for value in ["none", "optional", "require"]: - config = GRPCServerTLSConfig(client_auth=value) - assert config.client_auth == value.lower() - - def test_invalid_client_auth_value(self): - """Test invalid client_auth value raises ValueError.""" - with pytest.raises(ValueError, match="client_auth must be one of"): - GRPCServerTLSConfig(client_auth="invalid") - - def test_client_auth_case_insensitive(self): - """Test client_auth values are case-insensitive.""" - config = GRPCServerTLSConfig(client_auth="REQUIRE") - assert config.client_auth == "require" - - def test_from_env_empty(self): - """Test from_env returns None when no env vars set.""" - with patch.dict(os.environ, {}, clear=True): - result = GRPCServerTLSConfig.from_env() - assert result is None - - def test_from_env_with_values(self, tmp_path): - """Test from_env with environment variables.""" - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - ca_file = tmp_path / "ca.pem" - cert_file.touch() - key_file.touch() - ca_file.touch() - - env_vars = { - "PLUGINS_GRPC_SERVER_SSL_CERTFILE": str(cert_file), - "PLUGINS_GRPC_SERVER_SSL_KEYFILE": str(key_file), - "PLUGINS_GRPC_SERVER_SSL_CA_CERTS": str(ca_file), - "PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH": "optional", - } - with patch.dict(os.environ, env_vars, clear=True): - result = GRPCServerTLSConfig.from_env() - assert result is not None - assert result.certfile == str(cert_file) - assert result.keyfile == str(key_file) - assert result.ca_bundle == str(ca_file) - assert result.client_auth == "optional" - - -class TestGRPCClientConfig: - """Tests for GRPCClientConfig model.""" - - def test_target_only(self): - """Test configuration with target only.""" - config = GRPCClientConfig(target="localhost:50051") - assert config.target == "localhost:50051" - assert config.uds is None - assert config.get_target() == "localhost:50051" - - def test_uds_only(self, tmp_path): - """Test configuration with UDS only.""" - uds_path = str(tmp_path / "grpc.sock") - config = GRPCClientConfig(uds=uds_path) - assert config.uds == uds_path - assert config.target is None - assert config.get_target() == f"unix://{uds_path}" - - def test_target_validation(self): - """Test target must contain host:port format.""" - with pytest.raises(ValueError, match="must be in host:port format"): - GRPCClientConfig(target="localhost") - - def test_empty_target_rejected(self): - """Test empty target string is rejected.""" - with pytest.raises(ValueError, match="cannot be empty"): - GRPCClientConfig(target="") - - def test_uds_relative_path_rejected(self, tmp_path): - """Test relative UDS path is rejected because parent doesn't exist.""" - # Relative paths are resolved to absolute, then parent dir is checked - # The error will be about the parent directory not existing - with pytest.raises(ValueError, match="parent directory does not exist"): - GRPCClientConfig(uds="relative/path.sock") - - def test_uds_parent_must_exist(self, tmp_path): - """Test UDS parent directory must exist.""" - with pytest.raises(ValueError, match="parent directory does not exist"): - GRPCClientConfig(uds="/nonexistent/path/grpc.sock") - - def test_neither_target_nor_uds_rejected(self): - """Test configuration must have either target or uds.""" - with pytest.raises(ValueError, match="must have either 'target' or 'uds'"): - GRPCClientConfig() - - def test_both_target_and_uds_rejected(self, tmp_path): - """Test configuration cannot have both target and uds.""" - uds_path = str(tmp_path / "grpc.sock") - with pytest.raises(ValueError, match="cannot have both 'target' and 'uds'"): - GRPCClientConfig(target="localhost:50051", uds=uds_path) - - def test_uds_with_tls_rejected(self, tmp_path): - """Test TLS is not allowed with UDS.""" - uds_path = str(tmp_path / "grpc.sock") - tls_config = GRPCClientTLSConfig(verify=True) - with pytest.raises(ValueError, match="TLS configuration is not supported for Unix domain sockets"): - GRPCClientConfig(uds=uds_path, tls=tls_config) - - def test_target_with_tls_allowed(self): - """Test TLS is allowed with target.""" - tls_config = GRPCClientTLSConfig(verify=True) - config = GRPCClientConfig(target="localhost:50051", tls=tls_config) - assert config.tls is not None - assert config.tls.verify is True - - def test_get_target_tcp(self): - """Test get_target returns host:port for TCP.""" - config = GRPCClientConfig(target="example.com:50051") - assert config.get_target() == "example.com:50051" - - def test_get_target_uds(self, tmp_path): - """Test get_target returns unix:// format for UDS.""" - uds_path = str(tmp_path / "grpc.sock") - config = GRPCClientConfig(uds=uds_path) - assert config.get_target() == f"unix://{uds_path}" - - -class TestGRPCServerConfig: - """Tests for GRPCServerConfig model.""" - - def test_default_values(self): - """Test default server configuration values.""" - config = GRPCServerConfig() - assert config.host == "127.0.0.1" - assert config.port == 50051 - assert config.uds is None - assert config.tls is None - - def test_custom_host_port(self): - """Test custom host and port.""" - config = GRPCServerConfig(host="127.0.0.1", port=50052) - assert config.host == "127.0.0.1" - assert config.port == 50052 - assert config.get_bind_address() == "127.0.0.1:50052" - - def test_uds_configuration(self, tmp_path): - """Test UDS configuration.""" - uds_path = str(tmp_path / "grpc.sock") - config = GRPCServerConfig(uds=uds_path) - assert config.uds == uds_path - assert config.get_bind_address() == f"unix://{uds_path}" - - def test_uds_relative_path_rejected(self): - """Test relative UDS path is rejected because parent doesn't exist.""" - # Relative paths are resolved to absolute, then parent dir is checked - with pytest.raises(ValueError, match="parent directory does not exist"): - GRPCServerConfig(uds="relative/path.sock") - - def test_uds_nonexistent_parent_rejected(self): - """Test UDS with non-existent parent directory is rejected.""" - with pytest.raises(ValueError, match="parent directory does not exist"): - GRPCServerConfig(uds="/nonexistent/path/grpc.sock") - - def test_uds_with_tls_rejected(self, tmp_path): - """Test TLS is not allowed with UDS.""" - uds_path = str(tmp_path / "grpc.sock") - tls_config = GRPCServerTLSConfig() - with pytest.raises(ValueError, match="TLS configuration is not supported for Unix domain sockets"): - GRPCServerConfig(uds=uds_path, tls=tls_config) - - def test_tcp_with_tls_allowed(self): - """Test TLS is allowed with TCP binding.""" - tls_config = GRPCServerTLSConfig(client_auth="none") - config = GRPCServerConfig(host="0.0.0.0", port=50051, tls=tls_config) - assert config.tls is not None - assert config.tls.client_auth == "none" - - def test_get_bind_address_tcp(self): - """Test get_bind_address returns host:port for TCP.""" - config = GRPCServerConfig(host="192.168.1.1", port=50052) - assert config.get_bind_address() == "192.168.1.1:50052" - - def test_get_bind_address_uds(self, tmp_path): - """Test get_bind_address returns unix:// format for UDS.""" - uds_path = str(tmp_path / "grpc.sock") - config = GRPCServerConfig(uds=uds_path) - assert config.get_bind_address() == f"unix://{uds_path}" - - def test_from_env_empty(self): - """Test from_env returns None when no env vars set.""" - with patch.dict(os.environ, {}, clear=True): - result = GRPCServerConfig.from_env() - assert result is None - - def test_from_env_with_host_port(self): - """Test from_env with host and port.""" - env_vars = { - "PLUGINS_GRPC_SERVER_HOST": "127.0.0.1", - "PLUGINS_GRPC_SERVER_PORT": "50052", - } - with patch.dict(os.environ, env_vars, clear=True): - result = GRPCServerConfig.from_env() - assert result is not None - assert result.host == "127.0.0.1" - assert result.port == 50052 - - def test_from_env_with_uds(self, tmp_path): - """Test from_env with UDS.""" - uds_path = str(tmp_path / "grpc.sock") - env_vars = { - "PLUGINS_GRPC_SERVER_UDS": uds_path, - } - with patch.dict(os.environ, env_vars, clear=True): - result = GRPCServerConfig.from_env() - assert result is not None - assert result.uds == uds_path - - def test_from_env_with_tls(self, tmp_path): - """Test from_env with TLS enabled.""" - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - cert_file.touch() - key_file.touch() - - env_vars = { - "PLUGINS_GRPC_SERVER_HOST": "0.0.0.0", - "PLUGINS_GRPC_SERVER_PORT": "50051", - "PLUGINS_GRPC_SERVER_SSL_ENABLED": "true", - "PLUGINS_GRPC_SERVER_SSL_CERTFILE": str(cert_file), - "PLUGINS_GRPC_SERVER_SSL_KEYFILE": str(key_file), - } - with patch.dict(os.environ, env_vars, clear=True): - result = GRPCServerConfig.from_env() - assert result is not None - assert result.tls is not None - assert result.tls.certfile == str(cert_file) - - def test_from_env_invalid_port(self): - """Test from_env raises ValueError for invalid port.""" - env_vars = { - "PLUGINS_GRPC_SERVER_PORT": "invalid", - } - with patch.dict(os.environ, env_vars, clear=True): - with pytest.raises(ValueError, match="valid integer"): - GRPCServerConfig.from_env() - - -class TestGRPCConfigEdgeCases: - """Edge case tests for gRPC configuration models.""" - - def test_client_config_ipv6_target(self): - """Test client config with IPv6 target.""" - config = GRPCClientConfig(target="[::1]:50051") - assert config.target == "[::1]:50051" - assert config.get_target() == "[::1]:50051" - - def test_client_config_domain_with_port(self): - """Test client config with domain name.""" - config = GRPCClientConfig(target="grpc.example.com:50051") - assert config.target == "grpc.example.com:50051" - - def test_server_config_ipv6_host(self): - """Test server config with IPv6 host.""" - config = GRPCServerConfig(host="::", port=50051) - assert config.host == "::" - assert config.get_bind_address() == ":::50051" - - def test_uds_path_with_spaces(self, tmp_path): - """Test UDS path with spaces in name.""" - socket_dir = tmp_path / "my socket dir" - socket_dir.mkdir() - uds_path = str(socket_dir / "grpc.sock") - config = GRPCClientConfig(uds=uds_path) - assert " " in config.uds - - def test_uds_path_normalized(self, tmp_path): - """Test UDS path is normalized (resolved to canonical path).""" - uds_path = str(tmp_path / "subdir" / ".." / "grpc.sock") - config = GRPCClientConfig(uds=uds_path) - # Path should be resolved to canonical form - assert ".." not in config.uds diff --git a/tests/unit/cpex/framework/external/grpc/test_tls_utils.py b/tests/unit/cpex/framework/external/grpc/test_tls_utils.py deleted file mode 100644 index a1c649f9..00000000 --- a/tests/unit/cpex/framework/external/grpc/test_tls_utils.py +++ /dev/null @@ -1,443 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/grpc/test_tls_utils.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for gRPC TLS utilities. -Tests for create_client_credentials, create_server_credentials, and channel creation functions. -""" - -# Standard -from unittest.mock import MagicMock, patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import GRPCClientTLSConfig, GRPCServerTLSConfig - -# Check if grpc is available -try: - import grpc # noqa: F401 - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -class TestReadFile: - """Tests for the _read_file helper function.""" - - def test_read_file_success(self, tmp_path): - """Test reading a file successfully.""" - from cpex.framework.external.grpc.tls_utils import _read_file - - test_file = tmp_path / "test.txt" - test_content = b"test content" - test_file.write_bytes(test_content) - - result = _read_file(str(test_file)) - assert result == test_content - - def test_read_file_not_found(self): - """Test reading a non-existent file raises FileNotFoundError.""" - from cpex.framework.external.grpc.tls_utils import _read_file - - with pytest.raises(FileNotFoundError): - _read_file("/nonexistent/path/file.txt") - - def test_read_file_binary_content(self, tmp_path): - """Test reading binary content.""" - from cpex.framework.external.grpc.tls_utils import _read_file - - test_file = tmp_path / "binary.bin" - binary_content = bytes(range(256)) - test_file.write_bytes(binary_content) - - result = _read_file(str(test_file)) - assert result == binary_content - - -class TestCreateClientCredentials: - """Tests for create_client_credentials function.""" - - def test_minimal_config(self): - """Test creating credentials with minimal config.""" - from cpex.framework.external.grpc.tls_utils import create_client_credentials - - config = GRPCClientTLSConfig(verify=True) - - with patch("grpc.ssl_channel_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - result = create_client_credentials(config, "TestPlugin") - - mock_ssl.assert_called_once() - assert result is not None - - def test_with_ca_bundle(self, tmp_path): - """Test creating credentials with CA bundle.""" - from cpex.framework.external.grpc.tls_utils import create_client_credentials - - ca_file = tmp_path / "ca.pem" - ca_file.write_bytes(b"CA CERTIFICATE") - - config = GRPCClientTLSConfig(ca_bundle=str(ca_file), verify=True) - - with patch("grpc.ssl_channel_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_client_credentials(config, "TestPlugin") - - call_kwargs = mock_ssl.call_args[1] - assert call_kwargs["root_certificates"] == b"CA CERTIFICATE" - - def test_with_client_certificates(self, tmp_path): - """Test creating credentials with client certificates (mTLS).""" - from cpex.framework.external.grpc.tls_utils import create_client_credentials - - cert_file = tmp_path / "client.pem" - key_file = tmp_path / "client-key.pem" - cert_file.write_bytes(b"CLIENT CERT") - key_file.write_bytes(b"CLIENT KEY") - - config = GRPCClientTLSConfig( - certfile=str(cert_file), - keyfile=str(key_file), - verify=True, - ) - - with patch("grpc.ssl_channel_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_client_credentials(config, "TestPlugin") - - call_kwargs = mock_ssl.call_args[1] - assert call_kwargs["certificate_chain"] == b"CLIENT CERT" - assert call_kwargs["private_key"] == b"CLIENT KEY" - - def test_verify_disabled(self): - """Test creating credentials with verification disabled.""" - from cpex.framework.external.grpc.tls_utils import create_client_credentials - - config = GRPCClientTLSConfig(verify=False) - - with patch("grpc.ssl_channel_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_client_credentials(config, "InsecurePlugin") - - call_kwargs = mock_ssl.call_args[1] - # When verify is disabled, root_certificates should be None - assert call_kwargs["root_certificates"] is None - - def test_full_mtls_config(self, tmp_path): - """Test creating credentials with full mTLS configuration.""" - from cpex.framework.external.grpc.tls_utils import create_client_credentials - - ca_file = tmp_path / "ca.pem" - cert_file = tmp_path / "client.pem" - key_file = tmp_path / "client-key.pem" - ca_file.write_bytes(b"CA CERT") - cert_file.write_bytes(b"CLIENT CERT") - key_file.write_bytes(b"CLIENT KEY") - - config = GRPCClientTLSConfig( - ca_bundle=str(ca_file), - certfile=str(cert_file), - keyfile=str(key_file), - verify=True, - ) - - with patch("grpc.ssl_channel_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_client_credentials(config, "mTLSPlugin") - - call_kwargs = mock_ssl.call_args[1] - assert call_kwargs["root_certificates"] == b"CA CERT" - assert call_kwargs["certificate_chain"] == b"CLIENT CERT" - assert call_kwargs["private_key"] == b"CLIENT KEY" - - def test_missing_ca_file_raises_at_model_creation(self): - """Test that missing CA file raises ValueError during model creation. - - The Pydantic model validates that TLS files exist when the config is created. - """ - with pytest.raises(ValueError, match="TLS file path does not exist"): - GRPCClientTLSConfig(ca_bundle="/nonexistent/ca.pem", verify=True) - - def test_missing_cert_file_raises_at_model_creation(self, tmp_path): - """Test that missing cert file raises ValueError during model creation. - - The Pydantic model validates that TLS files exist when the config is created. - """ - key_file = tmp_path / "client-key.pem" - key_file.write_bytes(b"KEY") - - with pytest.raises(ValueError, match="TLS file path does not exist"): - GRPCClientTLSConfig( - certfile="/nonexistent/client.pem", - keyfile=str(key_file), - verify=True, - ) - - -class TestCreateServerCredentials: - """Tests for create_server_credentials function.""" - - def test_basic_tls_config(self, tmp_path): - """Test creating server credentials with basic TLS.""" - from cpex.framework.external.grpc.tls_utils import create_server_credentials - - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - cert_file.write_bytes(b"SERVER CERT") - key_file.write_bytes(b"SERVER KEY") - - config = GRPCServerTLSConfig( - certfile=str(cert_file), - keyfile=str(key_file), - client_auth="none", - ) - - with patch("grpc.ssl_server_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_server_credentials(config) - - call_kwargs = mock_ssl.call_args[1] - assert call_kwargs["private_key_certificate_chain_pairs"] == [(b"SERVER KEY", b"SERVER CERT")] - assert call_kwargs["require_client_auth"] is False - - def test_mtls_config_require(self, tmp_path): - """Test creating server credentials with mTLS (client auth required).""" - from cpex.framework.external.grpc.tls_utils import create_server_credentials - - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - ca_file = tmp_path / "ca.pem" - cert_file.write_bytes(b"SERVER CERT") - key_file.write_bytes(b"SERVER KEY") - ca_file.write_bytes(b"CA CERT") - - config = GRPCServerTLSConfig( - certfile=str(cert_file), - keyfile=str(key_file), - ca_bundle=str(ca_file), - client_auth="require", - ) - - with patch("grpc.ssl_server_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_server_credentials(config) - - call_kwargs = mock_ssl.call_args[1] - assert call_kwargs["root_certificates"] == b"CA CERT" - assert call_kwargs["require_client_auth"] is True - - def test_mtls_config_optional(self, tmp_path): - """Test creating server credentials with optional client auth.""" - from cpex.framework.external.grpc.tls_utils import create_server_credentials - - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - cert_file.write_bytes(b"SERVER CERT") - key_file.write_bytes(b"SERVER KEY") - - config = GRPCServerTLSConfig( - certfile=str(cert_file), - keyfile=str(key_file), - client_auth="optional", - ) - - with patch("grpc.ssl_server_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_server_credentials(config) - - call_kwargs = mock_ssl.call_args[1] - # "optional" maps to False in gRPC (no native optional support) - assert call_kwargs["require_client_auth"] is False - - def test_keyfile_without_certfile_raises_at_model_creation(self, tmp_path): - """Test that keyfile without certfile raises ValueError during model creation. - - The Pydantic model requires certfile when keyfile is specified. - """ - key_file = tmp_path / "server-key.pem" - key_file.write_bytes(b"KEY") - - with pytest.raises(ValueError, match="keyfile requires certfile"): - GRPCServerTLSConfig(keyfile=str(key_file), client_auth="none") - - def test_certfile_without_keyfile_allowed_at_model_creation(self, tmp_path): - """Test that certfile without keyfile is allowed during model creation. - - The model validation only requires certfile when keyfile is specified, - not the reverse. This is because certfile-only configs may be valid - for some use cases (e.g., when keyfile will be provided later). - """ - cert_file = tmp_path / "server.pem" - cert_file.write_bytes(b"CERT") - - # This should NOT raise - certfile alone is allowed - config = GRPCServerTLSConfig(certfile=str(cert_file), client_auth="none") - assert config.certfile == str(cert_file) - assert config.keyfile is None - - def test_nonexistent_file_raises_at_model_creation(self): - """Test that non-existent certificate files raise ValueError during model creation. - - The Pydantic model validates that TLS files exist when the config is created. - """ - with pytest.raises(ValueError, match="TLS file path does not exist"): - GRPCServerTLSConfig( - certfile="/nonexistent/server.pem", - keyfile="/nonexistent/server-key.pem", - client_auth="none", - ) - - -class TestCreateInsecureChannel: - """Tests for create_insecure_channel function.""" - - def test_creates_insecure_channel(self): - """Test creating an insecure channel.""" - from cpex.framework.external.grpc.tls_utils import create_insecure_channel - - with patch("grpc.aio.insecure_channel") as mock_channel: - mock_channel.return_value = MagicMock() - result = create_insecure_channel("localhost:50051") - - mock_channel.assert_called_once_with("localhost:50051") - assert result is not None - - def test_logs_warning(self): - """Test that creating insecure channel logs a warning.""" - from cpex.framework.external.grpc.tls_utils import create_insecure_channel - - with patch("grpc.aio.insecure_channel") as mock_channel: - mock_channel.return_value = MagicMock() - with patch("cpex.framework.external.grpc.tls_utils.logger") as mock_logger: - create_insecure_channel("localhost:50051") - mock_logger.warning.assert_called() - - -class TestCreateSecureChannel: - """Tests for create_secure_channel function.""" - - def test_creates_secure_channel(self, tmp_path): - """Test creating a secure channel.""" - from cpex.framework.external.grpc.tls_utils import create_secure_channel - - ca_file = tmp_path / "ca.pem" - ca_file.write_bytes(b"CA CERT") - - config = GRPCClientTLSConfig(ca_bundle=str(ca_file), verify=True) - - with patch("grpc.aio.secure_channel") as mock_channel: - with patch("grpc.ssl_channel_credentials") as mock_creds: - mock_channel.return_value = MagicMock() - mock_creds.return_value = MagicMock() - - result = create_secure_channel("localhost:50051", config, "TestPlugin") - - mock_channel.assert_called_once() - assert result is not None - - def test_passes_credentials(self): - """Test that credentials are passed to secure_channel.""" - from cpex.framework.external.grpc.tls_utils import create_secure_channel - - config = GRPCClientTLSConfig(verify=True) - - with patch("grpc.aio.secure_channel") as mock_channel: - with patch("grpc.ssl_channel_credentials") as mock_creds: - mock_credentials = MagicMock() - mock_creds.return_value = mock_credentials - mock_channel.return_value = MagicMock() - - create_secure_channel("localhost:50051", config, "TestPlugin") - - # Verify credentials were passed - call_args = mock_channel.call_args - assert call_args[0][0] == "localhost:50051" - assert call_args[0][1] == mock_credentials - - def test_logs_info(self): - """Test that creating secure channel logs info.""" - from cpex.framework.external.grpc.tls_utils import create_secure_channel - - config = GRPCClientTLSConfig(verify=True) - - with patch("grpc.aio.secure_channel") as mock_channel: - with patch("grpc.ssl_channel_credentials"): - mock_channel.return_value = MagicMock() - with patch("cpex.framework.external.grpc.tls_utils.logger") as mock_logger: - create_secure_channel("localhost:50051", config, "TestPlugin") - mock_logger.info.assert_called() - - -class TestTLSUtilsIntegration: - """Integration tests for TLS utilities.""" - - def test_client_credentials_chain(self, tmp_path): - """Test full chain of creating client credentials and channel.""" - from cpex.framework.external.grpc.tls_utils import create_client_credentials, create_secure_channel - - ca_file = tmp_path / "ca.pem" - cert_file = tmp_path / "client.pem" - key_file = tmp_path / "client-key.pem" - ca_file.write_bytes(b"CA") - cert_file.write_bytes(b"CERT") - key_file.write_bytes(b"KEY") - - config = GRPCClientTLSConfig( - ca_bundle=str(ca_file), - certfile=str(cert_file), - keyfile=str(key_file), - verify=True, - ) - - with patch("grpc.ssl_channel_credentials") as mock_creds: - with patch("grpc.aio.secure_channel") as mock_channel: - mock_creds.return_value = MagicMock() - mock_channel.return_value = MagicMock() - - # Create credentials - creds = create_client_credentials(config, "TestPlugin") - assert creds is not None - - # Create channel with same config - channel = create_secure_channel("localhost:50051", config, "TestPlugin") - assert channel is not None - - def test_server_credentials_all_client_auth_modes(self, tmp_path): - """Test server credentials with all client auth modes.""" - from cpex.framework.external.grpc.tls_utils import create_server_credentials - - cert_file = tmp_path / "server.pem" - key_file = tmp_path / "server-key.pem" - cert_file.write_bytes(b"CERT") - key_file.write_bytes(b"KEY") - - for mode, expected_require in [("none", False), ("optional", False), ("require", True)]: - config = GRPCServerTLSConfig( - certfile=str(cert_file), - keyfile=str(key_file), - client_auth=mode, - ) - - with patch("grpc.ssl_server_credentials") as mock_ssl: - mock_ssl.return_value = MagicMock() - create_server_credentials(config) - - call_kwargs = mock_ssl.call_args[1] - assert call_kwargs["require_client_auth"] is expected_require, f"Failed for mode={mode}" - - def test_missing_certfile_raises_value_error(self): - """Test create_server_credentials raises ValueError when certfile is missing.""" - from cpex.framework.external.grpc.tls_utils import create_server_credentials - - config = GRPCServerTLSConfig(client_auth="none") - assert config.certfile is None - - with pytest.raises(ValueError, match="certfile.*keyfile.*required"): - create_server_credentials(config) diff --git a/tests/unit/cpex/framework/external/mcp/__init__.py b/tests/unit/cpex/framework/external/mcp/__init__.py deleted file mode 100644 index 14857803..00000000 --- a/tests/unit/cpex/framework/external/mcp/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor -""" diff --git a/tests/unit/cpex/framework/external/mcp/server/__init__.py b/tests/unit/cpex/framework/external/mcp/server/__init__.py deleted file mode 100644 index 7ba5b6f6..00000000 --- a/tests/unit/cpex/framework/external/mcp/server/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/server/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor -""" diff --git a/tests/unit/cpex/framework/external/mcp/server/test_runtime.py b/tests/unit/cpex/framework/external/mcp/server/test_runtime.py deleted file mode 100644 index 6173ccd6..00000000 --- a/tests/unit/cpex/framework/external/mcp/server/test_runtime.py +++ /dev/null @@ -1,386 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/server/test_runtime.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests for external client on stdio. -""" - -# Standard -import asyncio -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock - -# Third-Party -import pytest - -import cpex.framework.external.mcp.server.runtime as runtime - -# First-Party -from cpex.framework import ( - GlobalContext, - PluginContext, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ResourceHookType, - ResourcePostFetchPayload, - ResourcePreFetchPayload, - ToolHookType, - ToolPostInvokePayload, - ToolPreInvokePayload, -) -from cpex.framework.external.mcp.server import ExternalPluginServer -from tests.unit.cpex.fixtures.common.models import ( - Message, - PromptResult, - Role, - TextContent, -) - - -@pytest.fixture -def server(): - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml") - asyncio.run(server.initialize()) - yield server - asyncio.run(server.shutdown()) - - -@pytest.fixture -def tool_server(): - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_tool_hooks.yaml") - asyncio.run(server.initialize()) - yield server - asyncio.run(server.shutdown()) - - -@pytest.mark.asyncio -async def test_get_plugin_configs(monkeypatch, server): - monkeypatch.setattr(runtime, "SERVER", server) - configs = await runtime.get_plugin_configs() - assert len(configs) > 0 - - -@pytest.mark.asyncio -async def test_get_plugin_config(monkeypatch, server): - monkeypatch.setattr(runtime, "SERVER", server) - config = await runtime.get_plugin_config(name="DenyListPlugin") - assert config["name"] == "DenyListPlugin" - - -@pytest.mark.asyncio -async def test_prompt_pre_fetch(monkeypatch, server): - monkeypatch.setattr(runtime, "SERVER", server) - payload = PromptPrehookPayload(prompt_id="123", args={"user": "This is so innovative"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await runtime.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - assert result - assert result["result"] - assert not result["result"]["continue_processing"] - - -@pytest.mark.asyncio -async def test_prompt_post_fetch(monkeypatch, server): - monkeypatch.setattr(runtime, "SERVER", server) - message = Message(content=TextContent(type="text", text="crap prompt"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - payload = PromptPosthookPayload(prompt_id="123", result=prompt_result) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await runtime.invoke_hook( - PromptHookType.PROMPT_POST_FETCH, "ReplaceBadWordsPlugin", payload.model_dump(), context.model_dump() - ) - assert result - assert result["result"] - assert result["result"]["continue_processing"] - assert "crap" not in result["result"]["modified_payload"] - - -@pytest.mark.asyncio -async def test_tool_pre_invoke(monkeypatch, tool_server): - monkeypatch.setattr(runtime, "SERVER", tool_server) - payload = ToolPreInvokePayload(name="test_tool", args={"arg0": "bad argument"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await runtime.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, "ToolTestPlugin", payload.model_dump(), context.model_dump() - ) - assert result - assert result["result"] - assert result["result"]["continue_processing"] - assert "bad" not in result["result"]["modified_payload"]["args"]["arg0"] - - -@pytest.mark.asyncio -async def test_tool_post_invoke(monkeypatch, tool_server): - monkeypatch.setattr(runtime, "SERVER", tool_server) - payload = ToolPostInvokePayload(name="test_tool", result={"message": "wrong result"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await runtime.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, "ToolTestPlugin", payload.model_dump(), context.model_dump() - ) - assert result - assert result["result"] - assert result["result"]["continue_processing"] - assert "wrong" not in result["result"]["modified_payload"]["result"]["message"] - - -@pytest.mark.asyncio -async def test_resource_pre_fetch(monkeypatch, server): - monkeypatch.setattr(runtime, "SERVER", server) - payload = ResourcePreFetchPayload(uri="resource", metadata={"arg0": "Good argument"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await runtime.invoke_hook( - ResourceHookType.RESOURCE_PRE_FETCH, "ResourceFilterExample", payload.model_dump(), context.model_dump() - ) - assert result - assert result["result"] - assert not result["result"]["continue_processing"] - - -@pytest.mark.asyncio -async def test_resource_post_fetch(monkeypatch, server): - monkeypatch.setattr(runtime, "SERVER", server) - payload = ResourcePostFetchPayload(uri="resource", content="content") - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await runtime.invoke_hook( - ResourceHookType.RESOURCE_POST_FETCH, "ResourceFilterExample", payload.model_dump(), context.model_dump() - ) - assert result - assert result["result"] - assert result["result"]["continue_processing"] - - -@pytest.mark.asyncio -async def test_get_plugin_configs_requires_server(monkeypatch): - monkeypatch.setattr(runtime, "SERVER", None) - with pytest.raises(RuntimeError): - await runtime.get_plugin_configs() - - -@pytest.mark.asyncio -async def test_get_plugin_config_returns_empty_dict(monkeypatch): - server = MagicMock() - server.get_plugin_config = AsyncMock(return_value=None) - monkeypatch.setattr(runtime, "SERVER", server) - result = await runtime.get_plugin_config(name="missing") - assert result == {} - - -@pytest.mark.asyncio -async def test_invoke_hook_requires_server(monkeypatch): - monkeypatch.setattr(runtime, "SERVER", None) - with pytest.raises(RuntimeError): - await runtime.invoke_hook("hook", "plugin", {}, {}) - - -def test_ssl_config_with_tls(tmp_path): - from cpex.framework.models import MCPServerConfig, MCPServerTLSConfig - - cert_path = tmp_path / "cert.pem" - key_path = tmp_path / "key.pem" - ca_path = tmp_path / "ca.pem" - cert_path.write_text("cert") - key_path.write_text("key") - ca_path.write_text("ca") - - config = MCPServerConfig( - host="127.0.0.1", - port=8000, - tls=MCPServerTLSConfig( - certfile=str(cert_path), - keyfile=str(key_path), - ca_bundle=str(ca_path), - keyfile_password="secret", - ssl_cert_reqs=2, - ), - ) - - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - ssl_config = runtime.SSLCapableFastMCP._get_ssl_config(server) - - assert ssl_config["ssl_keyfile"] == str(key_path) - assert ssl_config["ssl_certfile"] == str(cert_path) - assert ssl_config["ssl_ca_certs"] == str(ca_path) - assert ssl_config["ssl_keyfile_password"] == "secret" - - -@pytest.mark.asyncio -async def test_start_health_check_server(monkeypatch): - server = object.__new__(runtime.SSLCapableFastMCP) - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - - served = MagicMock() - - class DummyServer: - def __init__(self, config): - self.config = config - - async def serve(self): - served() - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) - served.assert_called_once() - - -@pytest.mark.asyncio -async def test_run_streamable_http_async_with_ssl(monkeypatch): - from cpex.framework.models import MCPServerConfig - - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = MCPServerConfig(host="127.0.0.1", port=8000) - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) - - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {"ssl_keyfile": "/tmp/key.pem"}) - monkeypatch.setattr(server, "_start_health_check_server", AsyncMock()) - - served = MagicMock() - - class DummyServer: - def __init__(self, config): - self.config = config - - async def serve(self): - served() - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) - assert server._start_health_check_server.await_count == 1 - served.assert_called_once() - - -@pytest.mark.asyncio -async def test_run_stdio_transport(monkeypatch): - created = {} - - class DummyServer: - async def initialize(self): - return True - - async def shutdown(self): - created["shutdown"] = True - - def get_server_config(self): - return None - - class DummyFastMCP: - def __init__(self, *args, **kwargs): - created["mcp"] = self - - def tool(self, name): - def decorator(fn): - created.setdefault("tools", []).append(name) - return fn - - return decorator - - async def run_stdio_async(self): - created["ran_stdio"] = True - - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "FastMCP", DummyFastMCP) - monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") - - try: - await runtime.run() - finally: - runtime.SERVER = None - - assert created["ran_stdio"] - assert created["shutdown"] - - -@pytest.mark.asyncio -async def test_run_stdio_transport_ignores_malformed_server_port(monkeypatch): - created = {} - - class DummyServer: - async def initialize(self): - return True - - async def shutdown(self): - created["shutdown"] = True - - def get_server_config(self): - return None - - class DummyFastMCP: - def __init__(self, *args, **kwargs): - created["mcp"] = self - - def tool(self, name): - def decorator(fn): - created.setdefault("tools", []).append(name) - return fn - - return decorator - - async def run_stdio_async(self): - created["ran_stdio"] = True - - from cpex.framework.settings import settings - - settings.cache_clear() - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "FastMCP", DummyFastMCP) - monkeypatch.setenv("PLUGINS_SERVER_PORT", "abc") - monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") - - try: - await runtime.run() - finally: - settings.cache_clear() - runtime.SERVER = None - - assert created["ran_stdio"] - assert created["shutdown"] - - -@pytest.mark.asyncio -async def test_run_http_transport(monkeypatch): - created = {} - - class DummyServer: - async def initialize(self): - return True - - async def shutdown(self): - created["shutdown"] = True - - def get_server_config(self): - return None - - class DummyMCP: - def __init__(self, *args, **kwargs): - created["mcp"] = self - - def tool(self, name): - def decorator(fn): - created.setdefault("tools", []).append(name) - return fn - - return decorator - - async def run_streamable_http_async(self): - created["ran_http"] = True - - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "SSLCapableFastMCP", DummyMCP) - monkeypatch.setenv("PLUGINS_TRANSPORT", "http") - - try: - await runtime.run() - finally: - runtime.SERVER = None - - assert created["ran_http"] - assert created["shutdown"] diff --git a/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py b/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py deleted file mode 100644 index b600a321..00000000 --- a/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py +++ /dev/null @@ -1,495 +0,0 @@ -# -*- coding: utf-8 -*- -"""Coverage tests for cpex.framework.external.mcp.server.runtime.""" - -# Standard -from types import SimpleNamespace -from unittest.mock import MagicMock - -# Third-Party -import pytest - -import cpex.framework.external.mcp.server.runtime as runtime - -# First-Party -from cpex.framework.models import MCPServerConfig - -# =========================================================================== -# Module-Level Tool Functions -# =========================================================================== - - -class TestModuleLevelTools: - @pytest.mark.asyncio - async def test_get_plugin_config_requires_server(self, monkeypatch): - monkeypatch.setattr(runtime, "SERVER", None) - with pytest.raises(RuntimeError, match="Plugin server not initialized"): - await runtime.get_plugin_config("anything") - - -# =========================================================================== -# SSLCapableFastMCP __init__ -# =========================================================================== - - -class TestSSLCapableFastMCPInit: - def test_kwargs_override_host_port(self): - config = MCPServerConfig(host="0.0.0.0", port=9000) - server = runtime.SSLCapableFastMCP( - server_config=config, - name="Test", - host="custom_host", - port=1234, - ) - assert server.settings.host == "custom_host" - assert server.settings.port == 1234 - - def test_uds_sets_transport_security(self, tmp_path): - uds_path = str(tmp_path / "plugin.sock") - config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) - server = runtime.SSLCapableFastMCP(server_config=config, name="UDSTest") - assert server.server_config.uds == uds_path - - def test_ssl_config_partial_tls_warns(self, tmp_path, caplog): - """TLS present but no keyfile/certfile returns empty dict + warning.""" - - cert_path = tmp_path / "cert.pem" - cert_path.write_text("cert") - - # Create a config object then patch tls to have certfile but no keyfile - config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - - # Manually set tls with no keyfile and no certfile - tls = MagicMock() - tls.keyfile = None - tls.certfile = None - tls.ca_bundle = None - server.server_config.tls = tls - - ssl_config = server._get_ssl_config() - assert ssl_config == {} - assert any("keyfile/certfile not configured" in r.message for r in caplog.records) - - def test_ssl_config_tls_without_ca_or_password(self, tmp_path): - """Exercise _get_ssl_config branches when optional TLS fields are missing.""" - from cpex.framework.models import MCPServerTLSConfig - - cert_path = tmp_path / "cert.pem" - key_path = tmp_path / "key.pem" - cert_path.write_text("cert") - key_path.write_text("key") - - config = MCPServerConfig( - host="127.0.0.1", - port=8000, - tls=MCPServerTLSConfig( - certfile=str(cert_path), - keyfile=str(key_path), - ca_bundle=None, - keyfile_password=None, - ssl_cert_reqs=2, - ), - ) - - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - - ssl_config = runtime.SSLCapableFastMCP._get_ssl_config(server) - assert ssl_config["ssl_keyfile"] == str(key_path) - assert ssl_config["ssl_certfile"] == str(cert_path) - assert "ssl_ca_certs" not in ssl_config - assert "ssl_keyfile_password" not in ssl_config - - -# =========================================================================== -# run_streamable_http_async -# =========================================================================== - - -class TestRunStreamableHTTPAsync: - @pytest.mark.asyncio - async def test_with_uds(self, tmp_path, monkeypatch): - uds_path = str(tmp_path / "plugin.sock") - config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) - - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) - - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) - - served = MagicMock() - - class DummyServer: - def __init__(self, config): - self.config = config - - async def serve(self): - served() - - configs_seen = [] - _ = runtime.uvicorn.Config - - def capture_config(**kwargs): - configs_seen.append(kwargs) - return SimpleNamespace(**kwargs) - - monkeypatch.setattr(runtime.uvicorn, "Config", capture_config) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) - - served.assert_called_once() - assert configs_seen[0].get("uds") == uds_path - assert "host" not in configs_seen[0] - assert "port" not in configs_seen[0] - - @pytest.mark.asyncio - async def test_no_ssl(self, monkeypatch): - config = MCPServerConfig(host="127.0.0.1", port=8000) - - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) - - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) - - served = MagicMock() - - class DummyServer: - def __init__(self, cfg): - pass - - async def serve(self): - served() - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) - served.assert_called_once() - - @pytest.mark.asyncio - async def test_metrics_disabled(self, monkeypatch): - config = MCPServerConfig(host="127.0.0.1", port=8000) - - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") - - routes_added = [] - app = SimpleNamespace(routes=routes_added) - server.streamable_http_app = lambda: app - - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) - monkeypatch.setenv("ENABLE_METRICS", "false") - - served = MagicMock() - - class DummyServer: - def __init__(self, cfg): - pass - - async def serve(self): - served() - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) - served.assert_called_once() - # Verify routes were added (health + metrics_disabled) - assert len(routes_added) >= 2 - - -# =========================================================================== -# _start_health_check_server endpoints -# =========================================================================== - - -class TestStartHealthCheckServerEndpoints: - @pytest.mark.asyncio - async def test_metrics_enabled_executes_health_and_metrics_endpoints(self, monkeypatch): - server = object.__new__(runtime.SSLCapableFastMCP) - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - - monkeypatch.setenv("ENABLE_METRICS", "true") - - called = {"health": False, "metrics": False} - - class DummyServer: - def __init__(self, config): - self.config = config - - async def serve(self): - for route in self.config.app.routes: - if getattr(route, "path", None) == "/health": - resp = await route.endpoint(None) - called["health"] = resp is not None - if getattr(route, "path", None) == "/metrics/prometheus": - resp = await route.endpoint(None) - called["metrics"] = resp is not None - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) - assert called["health"] is True - assert called["metrics"] is True - - @pytest.mark.asyncio - async def test_metrics_disabled_executes_metrics_disabled_endpoint(self, monkeypatch): - server = object.__new__(runtime.SSLCapableFastMCP) - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - - monkeypatch.setenv("ENABLE_METRICS", "false") - - called = {"disabled": False} - - class DummyServer: - def __init__(self, config): - self.config = config - - async def serve(self): - for route in self.config.app.routes: - if getattr(route, "path", None) == "/metrics/prometheus": - try: - resp = await route.endpoint(None) - except TypeError: - resp = await route.endpoint() - called["disabled"] = resp is not None - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) - assert called["disabled"] is True - - -# =========================================================================== -# run_streamable_http_async endpoints -# =========================================================================== - - -class TestRunStreamableHTTPAsyncEndpoints: - @pytest.mark.asyncio - async def test_metrics_enabled_executes_routes(self, monkeypatch): - config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) - - monkeypatch.setenv("ENABLE_METRICS", "true") - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) - - called = {"health": False, "metrics": False} - - class DummyServer: - def __init__(self, cfg): - self.config = cfg - - async def serve(self): - for route in self.config.app.routes: - if getattr(route, "path", None) == "/health": - called["health"] = (await route.endpoint(None)) is not None - if getattr(route, "path", None) == "/metrics/prometheus": - called["metrics"] = (await route.endpoint(None)) is not None - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) - assert called["health"] is True - assert called["metrics"] is True - - @pytest.mark.asyncio - async def test_metrics_disabled_executes_route(self, monkeypatch): - config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) - server.server_config = config - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) - - monkeypatch.setenv("ENABLE_METRICS", "false") - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) - - called = {"disabled": False} - - class DummyServer: - def __init__(self, cfg): - self.config = cfg - - async def serve(self): - for route in self.config.app.routes: - if getattr(route, "path", None) == "/metrics/prometheus": - try: - resp = await route.endpoint(None) - except TypeError: - resp = await route.endpoint() - called["disabled"] = resp is not None - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) - assert called["disabled"] is True - - -# =========================================================================== -# run() function -# =========================================================================== - - -class TestRunFunction: - @pytest.mark.asyncio - async def test_init_failure(self, monkeypatch): - class DummyServer: - async def initialize(self): - return False - - async def shutdown(self): - pass - - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - - await runtime.run() - # Should return early without error - runtime.SERVER = None - - @pytest.mark.asyncio - async def test_auto_detect_stdin_not_tty(self, monkeypatch): - created = {} - - class DummyServer: - async def initialize(self): - return True - - async def shutdown(self): - created["shutdown"] = True - - class DummyFastMCP: - def __init__(self, *args, **kwargs): - created["mcp"] = True - - def tool(self, name): - def decorator(fn): - return fn - - return decorator - - async def run_stdio_async(self): - created["ran_stdio"] = True - - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "FastMCP", DummyFastMCP) - monkeypatch.delenv("PLUGINS_TRANSPORT", raising=False) - monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: False)) - - await runtime.run() - - assert created.get("ran_stdio") is True - assert created.get("shutdown") is True - runtime.SERVER = None - - @pytest.mark.asyncio - async def test_auto_detect_stdin_tty_defaults_to_http_and_logs_metrics(self, monkeypatch): - created = {} - - class DummyServer: - async def initialize(self): - return True - - async def shutdown(self): - created["shutdown"] = True - - def get_server_config(self): - return MCPServerConfig(host="127.0.0.1", port=8000) - - class DummyMCP: - def __init__(self, *args, **kwargs): - created["mcp"] = True - - def tool(self, name): # noqa: ARG002 - def decorator(fn): - return fn - - return decorator - - async def run_streamable_http_async(self): - created["ran_http"] = True - - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "SSLCapableFastMCP", DummyMCP) - monkeypatch.delenv("PLUGINS_TRANSPORT", raising=False) - monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True)) - - mock_logger = MagicMock() - monkeypatch.setattr(runtime, "logger", mock_logger) - - await runtime.run() - - assert created.get("ran_http") is True - assert created.get("shutdown") is True - assert any("Prometheus metrics available" in str(call.args[0]) for call in mock_logger.info.call_args_list) - runtime.SERVER = None - - @pytest.mark.asyncio - async def test_exception_propagation(self, monkeypatch): - class DummyServer: - async def initialize(self): - return True - - async def shutdown(self): - pass - - def get_server_config(self): - return None - - class DummyMCP: - def __init__(self, *args, **kwargs): - pass - - def tool(self, name): - def decorator(fn): - return fn - - return decorator - - async def run_streamable_http_async(self): - raise RuntimeError("server crashed") - - monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "SSLCapableFastMCP", DummyMCP) - monkeypatch.setenv("PLUGINS_TRANSPORT", "http") - - with pytest.raises(RuntimeError, match="server crashed"): - await runtime.run() - - runtime.SERVER = None - - @pytest.mark.asyncio - async def test_health_check_metrics_disabled(self, monkeypatch): - """Test _start_health_check_server with ENABLE_METRICS=false.""" - server = object.__new__(runtime.SSLCapableFastMCP) - server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - - monkeypatch.setenv("ENABLE_METRICS", "false") - - served = MagicMock() - - class DummyServer: - def __init__(self, config): - self.config = config - - async def serve(self): - served() - - monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) - served.assert_called_once() diff --git a/tests/unit/cpex/framework/external/mcp/server/test_server.py b/tests/unit/cpex/framework/external/mcp/server/test_server.py deleted file mode 100644 index 3326e793..00000000 --- a/tests/unit/cpex/framework/external/mcp/server/test_server.py +++ /dev/null @@ -1,445 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/server/test_server.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Comprehensive unit tests for ExternalPluginServer. -""" - -# Standard -import os -from unittest.mock import Mock, patch - -# Third-Party -import pytest -import pytest_asyncio - -# First-Party -from cpex.framework import ( - GlobalContext, - PluginContext, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ToolHookType, - ToolPreInvokePayload, -) -from cpex.framework.errors import PluginError -from cpex.framework.external.mcp.server.server import ExternalPluginServer -from cpex.framework.models import MCPServerConfig, PluginErrorModel -from tests.unit.cpex.fixtures.common.models import ( - Message, - PromptResult, - Role, - TextContent, -) - - -@pytest_asyncio.fixture -def server_with_plugins(): - """Create a server with valid plugin configuration.""" - return ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml") - - -@pytest_asyncio.fixture -async def initialized_server(server_with_plugins): - """Create and initialize a server.""" - await server_with_plugins.initialize() - yield server_with_plugins - await server_with_plugins.shutdown() - - -class TestExternalPluginServerInit: - """Tests for ExternalPluginServer initialization.""" - - def test_init_with_config_path(self): - """Test initialization with explicit config path.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - assert server._config_path == "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - assert server._config is not None - assert server._plugin_manager is not None - - def test_init_with_env_var(self, monkeypatch): - """Test initialization using PLUGINS_CONFIG_PATH environment variable.""" - monkeypatch.setenv("PLUGINS_CONFIG_PATH", "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - server = ExternalPluginServer() - assert server._config_path == "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - assert server._config is not None - - def test_init_with_env_var_ignores_unrelated_invalid_plugin_fields(self, monkeypatch): - """Initialization should not fail on unrelated invalid plugin env variables.""" - monkeypatch.setenv("PLUGINS_CONFIG_PATH", "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - monkeypatch.setenv("PLUGINS_SERVER_PORT", "abc") - server = ExternalPluginServer() - assert server._config_path == "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - assert server._config is not None - - def test_init_with_default_path(self, monkeypatch): - """Test initialization falls back to resources/plugins/config.yaml for standalone servers.""" - monkeypatch.delenv("PLUGINS_CONFIG_PATH", raising=False) - with patch("cpex.framework.loader.config.ConfigLoader.load_config") as mock_load: - mock_load.return_value = Mock(plugins=[], server_settings=None) - server = ExternalPluginServer() - assert server._config_path == os.path.join(".", "resources", "plugins", "config.yaml") - - def test_init_with_nonexistent_config_returns_empty(self): - """ConfigLoader returns an empty config for a nonexistent path.""" - server = ExternalPluginServer(config_path="./nonexistent/path/config.yaml") - assert server._config is not None - assert server._config.plugins == [] - - -class TestGetPluginConfigs: - """Tests for get_plugin_configs method.""" - - @pytest.mark.asyncio - async def test_get_plugin_configs_multiple(self, server_with_plugins): - """Test getting multiple plugin configurations.""" - configs = await server_with_plugins.get_plugin_configs() - assert isinstance(configs, list) - assert len(configs) > 0 - # Verify each config is a dict with expected keys - for config in configs: - assert isinstance(config, dict) - assert "name" in config - - @pytest.mark.asyncio - async def test_get_plugin_configs_single(self): - """Test getting plugin configs with single plugin.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - configs = await server.get_plugin_configs() - assert len(configs) == 1 - assert configs[0]["name"] == "ReplaceBadWordsPlugin" - - @pytest.mark.asyncio - async def test_get_plugin_configs_empty(self): - """Test getting plugin configs when no plugins configured.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - # Mock empty plugins list - server._config.plugins = None - configs = await server.get_plugin_configs() - assert configs == [] - - -class TestGetPluginConfig: - """Tests for get_plugin_config method.""" - - @pytest.mark.asyncio - async def test_get_plugin_config_found(self, server_with_plugins): - """Test getting a specific plugin config by name.""" - config = await server_with_plugins.get_plugin_config(name="DenyListPlugin") - assert config is not None - assert config["name"] == "DenyListPlugin" - - @pytest.mark.asyncio - async def test_get_plugin_config_case_insensitive(self, server_with_plugins): - """Test that plugin name lookup is case-insensitive.""" - config = await server_with_plugins.get_plugin_config(name="denylistplugin") - assert config is not None - assert config["name"] == "DenyListPlugin" - - @pytest.mark.asyncio - async def test_get_plugin_config_not_found(self, server_with_plugins): - """Test getting a non-existent plugin config returns None.""" - config = await server_with_plugins.get_plugin_config(name="NonExistentPlugin") - assert config is None - - @pytest.mark.asyncio - async def test_get_plugin_config_empty_plugins(self): - """Test getting plugin config when no plugins configured.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - server._config.plugins = None - config = await server.get_plugin_config(name="AnyPlugin") - assert config is None - - -class TestInvokeHook: - """Tests for invoke_hook method.""" - - @pytest.mark.asyncio - async def test_invoke_hook_success(self, initialized_server): - """Test successful hook invocation.""" - payload = PromptPrehookPayload(prompt_id="123", name="test_prompt", args={"user": "This is so innovative"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "plugin_name" in result - assert result["plugin_name"] == "DenyListPlugin" - assert "result" in result - assert result["result"]["continue_processing"] is False - - @pytest.mark.asyncio - async def test_invoke_hook_with_context_update(self, initialized_server): - """Test that hook invocation includes updated context in response.""" - payload = PromptPrehookPayload(prompt_id="123", name="test_prompt", args={"user": "normal text"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "plugin_name" in result - # Context may or may not be included depending on whether it was modified - - @pytest.mark.asyncio - async def test_invoke_hook_plugin_error(self, initialized_server): - """Test hook invocation when plugin raises PluginError.""" - with patch("cpex.framework.manager.PluginManager.invoke_hook_for_plugin") as mock_invoke: - # Simulate a PluginError - error = PluginErrorModel(message="Test error", plugin_name="TestPlugin", code="TEST_ERROR") - mock_invoke.side_effect = PluginError(error=error) - - payload = PromptPrehookPayload(prompt_id="123", args={}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "error" in result - # error is a PluginErrorModel object, not a dict - error_obj = result["error"] - assert isinstance(error_obj, PluginErrorModel) - assert error_obj.message == "Test error" - assert error_obj.plugin_name == "TestPlugin" - - @pytest.mark.asyncio - async def test_invoke_hook_generic_exception(self, initialized_server): - """Test hook invocation when plugin raises generic exception.""" - with patch("cpex.framework.manager.PluginManager.invoke_hook_for_plugin") as mock_invoke: - # Simulate a generic exception - mock_invoke.side_effect = ValueError("Unexpected error") - - payload = PromptPrehookPayload(prompt_id="123", args={}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "error" in result - assert "Unexpected error" in result["error"]["message"] - assert result["error"]["plugin_name"] == "DenyListPlugin" - - @pytest.mark.asyncio - async def test_invoke_hook_invalid_context(self, initialized_server): - """Test hook invocation with invalid context data returns error.""" - payload = PromptPrehookPayload(prompt_id="123", args={}) - # Invalid context dict - invalid_context = {"invalid": "data"} - - # The method catches exceptions and returns them in the result - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), invalid_context - ) - - # Should return an error result instead of raising - assert result is not None - assert "error" in result - - @pytest.mark.asyncio - async def test_invoke_hook_tool_hooks(self, initialized_server): - """Test invoking tool pre/post hooks.""" - # Test tool pre-invoke - payload = ToolPreInvokePayload(name="test_tool", args={"arg": "value"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, "ReplaceBadWordsPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "plugin_name" in result - assert result["plugin_name"] == "ReplaceBadWordsPlugin" - - @pytest.mark.asyncio - async def test_invoke_hook_prompt_post_fetch(self, initialized_server): - """Test invoking prompt post-fetch hook.""" - message = Message(content=TextContent(type="text", text="test content"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - payload = PromptPosthookPayload(prompt_id="123", result=prompt_result) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_POST_FETCH, "ReplaceBadWordsPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "plugin_name" in result - assert result["plugin_name"] == "ReplaceBadWordsPlugin" - - -class TestInitializeShutdown: - """Tests for initialize and shutdown methods.""" - - @pytest.mark.asyncio - async def test_initialize_success(self, server_with_plugins): - """Test successful initialization.""" - result = await server_with_plugins.initialize() - assert result is True - assert server_with_plugins._plugin_manager.initialized is True - await server_with_plugins.shutdown() - - @pytest.mark.asyncio - async def test_initialize_idempotent(self, server_with_plugins): - """Test that multiple initializations are safe.""" - await server_with_plugins.initialize() - await server_with_plugins.initialize() - # Should still return True - assert server_with_plugins._plugin_manager.initialized is True - await server_with_plugins.shutdown() - - @pytest.mark.asyncio - async def test_shutdown_when_initialized(self, initialized_server): - """Test shutdown on initialized server.""" - assert initialized_server._plugin_manager.initialized is True - await initialized_server.shutdown() - assert initialized_server._plugin_manager.initialized is False - - @pytest.mark.asyncio - async def test_shutdown_when_not_initialized(self, server_with_plugins): - """Test shutdown on non-initialized server (should be safe).""" - assert server_with_plugins._plugin_manager.initialized is False - # Should not raise an error - await server_with_plugins.shutdown() - assert server_with_plugins._plugin_manager.initialized is False - - @pytest.mark.asyncio - async def test_shutdown_idempotent(self, initialized_server): - """Test that multiple shutdowns are safe.""" - await initialized_server.shutdown() - # Second shutdown should be safe - await initialized_server.shutdown() - - -class TestGetServerConfig: - """Tests for get_server_config method.""" - - def test_get_server_config_with_settings(self): - """Test getting server config when server_settings is configured.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - - # Mock server settings - expected_config = MCPServerConfig(host="0.0.0.0", port=8080, tls_enabled=False) - server._config.server_settings = expected_config - - config = server.get_server_config() - assert config == expected_config - assert config.host == "0.0.0.0" - assert config.port == 8080 - - def test_get_server_config_from_env(self): - """Test getting server config from environment variables.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - server._config.server_settings = None - - # Set environment variables - os.environ["MCP_SERVER_HOST"] = "127.0.0.1" - os.environ["MCP_SERVER_PORT"] = "9090" - - try: - config = server.get_server_config() - assert config is not None - # Should have loaded from env or defaults - finally: - # Cleanup - os.environ.pop("MCP_SERVER_HOST", None) - os.environ.pop("MCP_SERVER_PORT", None) - - def test_get_server_config_defaults(self): - """Test getting server config with defaults.""" - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - server._config.server_settings = None - - config = server.get_server_config() - assert config is not None - assert isinstance(config, MCPServerConfig) - - def test_get_server_config_with_tls(self, tmp_path): - """Test getting server config with TLS enabled.""" - # First-Party - from cpex.framework.models import MCPServerTLSConfig - - server = ExternalPluginServer(config_path="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - - # Create dummy cert files for validation - cert_file = tmp_path / "cert.pem" - key_file = tmp_path / "key.pem" - cert_file.write_text("cert") - key_file.write_text("key") - - tls_settings = MCPServerTLSConfig(certfile=str(cert_file), keyfile=str(key_file)) - tls_config = MCPServerConfig(host="0.0.0.0", port=8443, tls=tls_settings) - server._config.server_settings = tls_config - - config = server.get_server_config() - assert config.tls is not None - assert config.tls.certfile == str(cert_file) - assert config.tls.keyfile == str(key_file) - - -class TestEdgeCases: - """Tests for edge cases and error conditions.""" - - def test_doctest_example(self): - """Test the doctest example from __init__.""" - server = ExternalPluginServer( - config_path="./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - ) - assert server is not None - - @pytest.mark.asyncio - async def test_doctest_get_plugin_configs(self): - """Test the doctest example from get_plugin_configs.""" - server = ExternalPluginServer( - config_path="./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - ) - plugins = await server.get_plugin_configs() - assert len(plugins) > 0 - - @pytest.mark.asyncio - async def test_doctest_get_plugin_config(self): - """Test the doctest example from get_plugin_config.""" - server = ExternalPluginServer( - config_path="./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - ) - config = await server.get_plugin_config(name="DenyListPlugin") - assert config is not None - assert config["name"] == "DenyListPlugin" - - @pytest.mark.asyncio - async def test_invoke_hook_with_empty_payload(self, initialized_server): - """Test hook invocation with minimal/empty payload.""" - payload = PromptPrehookPayload(prompt_id="123", args={}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "plugin_name" in result - - @pytest.mark.asyncio - async def test_invoke_hook_with_complex_payload(self, initialized_server): - """Test hook invocation with multiple arguments.""" - # PromptPrehookPayload args values must be strings - payload = PromptPrehookPayload( - prompt_id="123", args={"user": "test message", "system": "system prompt", "context": "additional context"} - ) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - result = await initialized_server.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, "DenyListPlugin", payload.model_dump(), context.model_dump() - ) - - assert result is not None - assert "plugin_name" in result diff --git a/tests/unit/cpex/framework/external/mcp/test_client_certificate_validation.py b/tests/unit/cpex/framework/external/mcp/test_client_certificate_validation.py deleted file mode 100644 index ec4af79d..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_client_certificate_validation.py +++ /dev/null @@ -1,482 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/test_client_certificate_validation.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Tests for TLS/mTLS certificate validation in external plugin client. -""" - -# Standard -import datetime -import ssl -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest - -# Third-Party -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import ExtensionOID, NameOID - -# First-Party -from cpex.framework.external.mcp.tls_utils import create_ssl_context -from cpex.framework.models import MCPClientTLSConfig - - -def generate_self_signed_cert( - tmp_path: Path, common_name: str = "localhost", expired: bool = False -) -> tuple[Path, Path]: - """Generate a self-signed certificate for testing. - - Args: - tmp_path: Temporary directory path - common_name: Common name for the certificate - expired: If True, create an already-expired certificate - - Returns: - Tuple of (cert_path, key_path) - """ - # Generate private key - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) - - # Certificate validity period - if expired: - # Create an expired certificate (valid from 2 years ago to 1 year ago) - not_valid_before = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=730) - not_valid_after = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=365) - else: - # Create a valid certificate (valid from now for 365 days) - not_valid_before = datetime.datetime.now(tz=datetime.timezone.utc) - not_valid_after = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=365) - - # Create certificate - subject = issuer = x509.Name( - [ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), - x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Org"), - x509.NameAttribute(NameOID.COMMON_NAME, common_name), - ] - ) - - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(private_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_valid_before) - .not_valid_after(not_valid_after) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName(common_name)]), - critical=False, - ) - .sign(private_key, hashes.SHA256(), default_backend()) - ) - - # Write certificate - cert_path = tmp_path / f"{common_name}_cert.pem" - cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - - # Write private key - key_path = tmp_path / f"{common_name}_key.pem" - key_path.write_bytes( - private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - - return cert_path, key_path - - -def generate_ca_and_signed_cert(tmp_path: Path, common_name: str = "localhost") -> tuple[Path, Path, Path]: - """Generate a CA certificate and a certificate signed by that CA. - - Args: - tmp_path: Temporary directory path - common_name: Common name for the server certificate - - Returns: - Tuple of (ca_cert_path, server_cert_path, server_key_path) - """ - # Generate CA private key - ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) - - # Create CA certificate - ca_subject = x509.Name( - [ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), - x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test CA"), - x509.NameAttribute(NameOID.COMMON_NAME, "Test CA"), - ] - ) - - ca_cert = ( - x509.CertificateBuilder() - .subject_name(ca_subject) - .issuer_name(ca_subject) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.now(tz=datetime.timezone.utc)) - .not_valid_after(datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=3650)) - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), - critical=True, - ) - .sign(ca_key, hashes.SHA256(), default_backend()) - ) - - # Generate server private key - server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) - - # Create server certificate signed by CA - server_subject = x509.Name( - [ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), - x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Server"), - x509.NameAttribute(NameOID.COMMON_NAME, common_name), - ] - ) - - server_cert = ( - x509.CertificateBuilder() - .subject_name(server_subject) - .issuer_name(ca_subject) - .public_key(server_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.now(tz=datetime.timezone.utc)) - .not_valid_after(datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=365)) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName(common_name)]), - critical=False, - ) - .sign(ca_key, hashes.SHA256(), default_backend()) - ) - - # Write CA certificate - ca_cert_path = tmp_path / "ca_cert.pem" - ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM)) - - # Write server certificate - server_cert_path = tmp_path / f"{common_name}_cert.pem" - server_cert_path.write_bytes(server_cert.public_bytes(serialization.Encoding.PEM)) - - # Write server private key - server_key_path = tmp_path / f"{common_name}_key.pem" - server_key_path.write_bytes( - server_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - - return ca_cert_path, server_cert_path, server_key_path - - -def test_ssl_context_configured_for_certificate_validation(tmp_path): - """Test that create_ssl_context() configures SSL context for certificate validation. - - This validates that the SSL context is configured with CERT_REQUIRED mode, - which will reject invalid certificates (like self-signed certs) during - TLS handshake. - - This test validates the actual production code path used in client.py. - Note: This tests configuration, not actual rejection. See - test_ssl_context_rejects_invalid_certificate for rejection behavior. - """ - # Generate self-signed certificate (not signed by a trusted CA) - cert_path, _key_path = generate_self_signed_cert(tmp_path, common_name="untrusted.example.com") - - # Create TLS config pointing to self-signed cert as CA - # This simulates a server presenting a self-signed certificate - tls_config = MCPClientTLSConfig( - ca_bundle=str(cert_path), certfile=None, keyfile=None, verify=True, check_hostname=True - ) - - # Create SSL context using the production utility function - # This is the same function used in client.py for external plugin connections - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify the context has strict validation enabled - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is True - - # Note: We can't easily test the actual connection failure without spinning up - # a real HTTPS server, but we can verify the SSL context is configured correctly - # to reject invalid certificates - - -def test_ssl_context_rejects_invalid_certificate(): - """Test that SSL context with CERT_REQUIRED will reject invalid certificates. - - This test demonstrates the rejection behavior by showing that: - 1. An SSL context created with verify=True has CERT_REQUIRED mode - 2. CERT_REQUIRED mode means OpenSSL will reject invalid certificates during handshake - 3. The rejection is simulated since we can't easily spin up a real HTTPS server - - Per Python SSL docs: "If CERT_REQUIRED is used, the client or server must provide - a valid and trusted certificate. A connection attempt will raise an SSLError if - the certificate validation fails." - - This validates the actual rejection behavior mechanism. - """ - import tempfile - - # Create a valid self-signed CA certificate for testing - with tempfile.TemporaryDirectory() as tmpdir: - ca_cert_path, _ca_key_path = generate_self_signed_cert(Path(tmpdir), common_name="TestCA") - - # Create TLS config with strict verification - tls_config = MCPClientTLSConfig( - ca_bundle=str(ca_cert_path), certfile=None, keyfile=None, verify=True, check_hostname=True - ) - - # Create SSL context - this will succeed (configuration step) - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify the context requires certificate validation - assert ssl_context.verify_mode == ssl.CERT_REQUIRED, "Should require certificate verification" - assert ssl_context.check_hostname is True, "Should verify hostname" - - # The key point: When this SSL context is used in a real connection: - # - If server presents a certificate NOT signed by our test CA -> SSLError - # - If server presents an expired certificate -> SSLError - # - If server presents a certificate with wrong hostname -> SSLError - # - If server doesn't present a certificate -> SSLError - # - # This is guaranteed by the CERT_REQUIRED setting and documented in: - # - Python SSL docs: https://docs.python.org/3/library/ssl.html#ssl.CERT_REQUIRED - # - OpenSSL verify docs: https://docs.openssl.org/3.1/man1/openssl-verification-options/ - # - RFC 5280 Section 6: Certificate path validation - - # To demonstrate, we can show that attempting to verify a different certificate - # would fail. Here's what the SSL context will do during handshake: - with patch("ssl.SSLContext.wrap_socket") as mock_wrap: - # Simulate what happens when OpenSSL rejects the certificate - mock_wrap.side_effect = ssl.SSLError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") - - # This is what would happen if we tried to connect to a server - # with an invalid certificate: - with pytest.raises(ssl.SSLError, match="CERTIFICATE_VERIFY_FAILED"): - ssl_context.wrap_socket(Mock(), server_hostname="example.com") - - -def test_ssl_context_accepts_valid_ca_signed_certificate(tmp_path): - """Test that create_ssl_context() accepts certificates signed by a trusted CA. - - This validates that certificate chain validation works correctly when - a proper CA certificate is provided. - - This test validates the actual production code path used in client.py. - """ - # Generate CA and a certificate signed by that CA - ca_cert_path, server_cert_path, server_key_path = generate_ca_and_signed_cert( - tmp_path, common_name="valid.example.com" - ) - - # Create TLS config with the CA certificate - tls_config = MCPClientTLSConfig( - ca_bundle=str(ca_cert_path), - certfile=str(server_cert_path), - keyfile=str(server_key_path), - verify=True, - check_hostname=True, - ) - - # Create SSL context using the production utility function - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify the context is configured for strict validation - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is True - - # Verify we can load the certificate successfully - # In a real scenario, this would successfully connect to a server - # presenting a certificate signed by our CA - - -def test_expired_certificate_detection(tmp_path): - """Test that expired certificates can be detected. - - Per OpenSSL docs and RFC 5280: Certificate validity period (notBefore/notAfter) - is automatically checked during validation. This test verifies we can - generate expired certificates that would fail validation. - - This test validates the actual production code path used in client.py. - """ - # Generate an already-expired certificate - cert_path, _key_path = generate_self_signed_cert(tmp_path, common_name="expired.example.com", expired=True) - - # Load the certificate and verify it's expired - with open(cert_path, "rb") as f: - cert_data = f.read() - cert = x509.load_pem_x509_certificate(cert_data, default_backend()) - - # Verify the certificate is expired - now = datetime.datetime.now(tz=datetime.timezone.utc) - assert cert.not_valid_after_utc < now, "Certificate should be expired" - assert cert.not_valid_before_utc < now, "Certificate notBefore should be in the past" - - # Create TLS config with the expired certificate - tls_config = MCPClientTLSConfig( - ca_bundle=str(cert_path), certfile=None, keyfile=None, verify=True, check_hostname=False - ) - - # Create SSL context using the production utility function - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify the context has verification enabled - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - - # We've verified the certificate is expired - in actual usage, - # create_ssl_context() with CERT_REQUIRED would automatically - # reject this during the TLS handshake - - -def test_certificate_validity_period_future(tmp_path): - """Test detection of certificates that are not yet valid (notBefore in future). - - Per OpenSSL docs: Certificates with notBefore date after current time - are rejected with "certificate is not yet valid" error. - """ - # Generate private key - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) - - # Create certificate with notBefore in the future - not_valid_before = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=30) - not_valid_after = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=395) - - subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "future.example.com")]) - - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(private_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_valid_before) - .not_valid_after(not_valid_after) - .sign(private_key, hashes.SHA256(), default_backend()) - ) - - # Write certificate - cert_path = tmp_path / "future_cert.pem" - cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - - # Verify the certificate is not yet valid - now = datetime.datetime.now(tz=datetime.timezone.utc) - assert cert.not_valid_before_utc > now, "Certificate should not yet be valid" - - # In actual usage, ssl.create_default_context() would reject this certificate - # during validation with "certificate is not yet valid" - - -def test_ssl_context_configuration_for_mtls(tmp_path): - """Test that SSL context is properly configured for mTLS. - - This test verifies that the SSL context configuration matches the - security requirements for mutual TLS authentication. - - This test validates the actual production code path used in client.py. - """ - # Generate CA and certificates - ca_cert_path, client_cert_path, client_key_path = generate_ca_and_signed_cert( - tmp_path, common_name="client.example.com" - ) - - # Create TLS config for mTLS - tls_config = MCPClientTLSConfig( - ca_bundle=str(ca_cert_path), - certfile=str(client_cert_path), - keyfile=str(client_key_path), - verify=True, - check_hostname=True, - ) - - # Create SSL context using the production utility function - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify security settings - assert ssl_context.verify_mode == ssl.CERT_REQUIRED, "Should require certificate verification" - assert ssl_context.check_hostname is True, "Should verify hostname by default" - - # Verify protocol restrictions (no SSLv2, SSLv3) - # create_ssl_context() automatically disables weak protocols - assert ssl_context.minimum_version >= ssl.TLSVersion.TLSv1_2, "Should use TLS 1.2 or higher" - - -def test_ssl_context_with_verification_disabled(tmp_path): - """Test SSL context when certificate verification is explicitly disabled. - - When verify=False, the SSL context should allow connections without - certificate validation. This is useful for testing but not recommended - for production. - - This test validates the actual production code path used in client.py. - """ - # Generate self-signed certificate - cert_path, _key_path = generate_self_signed_cert(tmp_path, common_name="novalidate.example.com") - - # Create TLS config with verification disabled - tls_config = MCPClientTLSConfig( - ca_bundle=str(cert_path), certfile=None, keyfile=None, verify=False, check_hostname=False - ) - - # Create SSL context using the production utility function - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify security is disabled as configured - assert ssl_context.verify_mode == ssl.CERT_NONE, "Verification should be disabled" - assert ssl_context.check_hostname is False, "Hostname checking should be disabled" - - -def test_certificate_with_wrong_hostname_would_fail(tmp_path): - """Test that hostname verification would reject certificates with wrong hostname. - - Per Python ssl docs: When check_hostname is enabled, the certificate's - Subject Alternative Name (SAN) or Common Name (CN) must match the hostname. - - This test validates the actual production code path used in client.py. - """ - # Generate certificate for one hostname - cert_path, _key_path = generate_self_signed_cert(tmp_path, common_name="correct.example.com") - - # Load the certificate - with open(cert_path, "rb") as f: - cert_data = f.read() - cert = x509.load_pem_x509_certificate(cert_data, default_backend()) - - # Verify the certificate has the correct hostname in SAN - san_extension = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) - san_names = san_extension.value.get_values_for_type(x509.DNSName) - - assert "correct.example.com" in san_names, "Certificate should have correct.example.com in SAN" - assert "wrong.example.com" not in san_names, "Certificate should not have wrong.example.com in SAN" - - # Create TLS config with hostname checking enabled - tls_config = MCPClientTLSConfig( - ca_bundle=str(cert_path), certfile=None, keyfile=None, verify=True, check_hostname=True - ) - - # Create SSL context using the production utility function - ssl_context = create_ssl_context(tls_config, "TestPlugin") - - # Verify hostname checking is enabled - assert ssl_context.check_hostname is True, "Hostname checking should be enabled" - assert ssl_context.verify_mode == ssl.CERT_REQUIRED, "Certificate verification should be required" - - # In actual usage, connecting to "wrong.example.com" with this certificate - # would fail with: ssl.CertificateError: hostname 'wrong.example.com' - # doesn't match 'correct.example.com' diff --git a/tests/unit/cpex/framework/external/mcp/test_client_config.py b/tests/unit/cpex/framework/external/mcp/test_client_config.py deleted file mode 100644 index 5b05a353..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_client_config.py +++ /dev/null @@ -1,321 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/test_client_config.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Additional unit tests for ExternalPlugin client. -Tests for error conditions, edge cases, and uncovered code paths. -""" - -# Standard -import os -import sys -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -# Third-Party -import pytest -from mcp.types import CallToolResult -from mcp.types import TextContent as MCPTextContent - -# First-Party -from cpex.framework import ( - ConfigLoader, - GlobalContext, - MCPClientConfig, - PluginContext, - PluginError, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ResourceHookType, - ResourcePostFetchPayload, - ResourcePreFetchPayload, - ToolHookType, - ToolPostInvokePayload, - ToolPreInvokePayload, - TransportType, -) -from cpex.framework.external.mcp.client import ExternalPlugin -from tests.unit.cpex.fixtures.common.models import ( - Message, - PromptResult, - ResourceContent, - Role, - TextContent, -) - - -@pytest.mark.asyncio -async def test_initialize_missing_mcp_config(): - """Test initialize raises ValueError when mcp config is missing.""" - # Use a real config but temporarily remove mcp section - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - - # Create plugin with mcp removed via frozen-safe copy - no_mcp_config = plugin_config.model_copy(update={"mcp": None}) - plugin = ExternalPlugin(no_mcp_config) - - with pytest.raises(PluginError, match="The mcp section must be defined for external plugin"): - await plugin.initialize() - - -@pytest.mark.asyncio -async def test_initialize_stdio_missing_script(): - """Test initialize raises ValueError for missing stdio script.""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - - # Create plugin with missing script via frozen-safe copy - bad_mcp = plugin_config.mcp.model_copy(update={"script": "/path/to/missing.sh"}) - bad_config = plugin_config.model_copy(update={"mcp": bad_mcp}) - plugin = ExternalPlugin(bad_config) - - # Cross-platform: Windows uses backslashes, Unix uses forward slashes - with pytest.raises(PluginError, match=r"Server script .+[/\\]missing\.sh does not exist\."): - await plugin.initialize() - - -@pytest.mark.asyncio -async def test_resolve_stdio_command_from_cmd(): - """Test cmd-based stdio command resolution.""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - command, args = plugin._ExternalPlugin__resolve_stdio_command(None, ["node", "server.js", "--flag"], None) - assert command == "node" - assert args == ["server.js", "--flag"] - - -@pytest.mark.asyncio -async def test_resolve_stdio_command_from_script_py(): - """Test script-based stdio command resolution for Python scripts.""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - script_path = "cpex/framework/external/mcp/server/runtime.py" - command, args = plugin._ExternalPlugin__resolve_stdio_command(script_path, None, None) - assert command == sys.executable - assert len(args) == 1 - assert Path(args[0]) == Path(script_path) - - -@pytest.mark.asyncio -async def test_initialize_config_retrieval_failure(): - """Test initialize raises ValueError when plugin config retrieval fails.""" - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - os.environ["PYTHONPATH"] = "." - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - # Mock stdio connection to succeed but config retrieval to fail - mock_stdio = Mock() - mock_write = Mock() - mock_session = AsyncMock() - mock_session.initialize = AsyncMock() - mock_session.list_tools = AsyncMock() - mock_session.list_tools.return_value.tools = [] - - # Mock get_plugin_config to return empty content (failure) - mock_session.call_tool = AsyncMock() - mock_session.call_tool.return_value = CallToolResult(content=[]) - - with ( - patch("cpex.framework.external.mcp.client.stdio_client") as mock_stdio_client, - patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), - ): - mock_stdio_client.return_value.__aenter__ = AsyncMock(return_value=(mock_stdio, mock_write)) - mock_stdio_client.return_value.__aexit__ = AsyncMock(return_value=False) - - # This test is about config retrieval failure, not missing script - with pytest.raises(PluginError, match="Unable to retrieve configuration for external plugin"): - await plugin.initialize() - - # Cleanup - if "PLUGINS_CONFIG_PATH" in os.environ: - del os.environ["PLUGINS_CONFIG_PATH"] - if "PYTHONPATH" in os.environ: - del os.environ["PYTHONPATH"] - - -@pytest.mark.asyncio -async def test_hook_methods_empty_content(): - """Test hook methods raise PluginError when content is empty.""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - # Set up session mock - mock_session = AsyncMock() - plugin._session = mock_session - - # Mock empty content response - mock_session.call_tool = AsyncMock() - mock_session.call_tool.return_value = CallToolResult(content=[]) - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - - # Test prompt_pre_fetch with empty content - should raise PluginError - payload = PromptPrehookPayload(prompt_id="1", args={}) - with pytest.raises(PluginError): - await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, context) - - # Test prompt_post_fetch with empty content - should raise PluginError - message = Message(content=TextContent(type="text", text="test"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - payload = PromptPosthookPayload(prompt_id="1", result=prompt_result) - with pytest.raises(PluginError): - await plugin.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload, context) - - # Test tool_pre_invoke with empty content - should raise PluginError - payload = ToolPreInvokePayload(name="test", args={}) - with pytest.raises(PluginError): - await plugin.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, payload, context) - - # Test tool_post_invoke with empty content - should raise PluginError - payload = ToolPostInvokePayload(name="test", result={}) - with pytest.raises(PluginError): - await plugin.invoke_hook(ToolHookType.TOOL_POST_INVOKE, payload, context) - - # Test resource_pre_fetch with empty content - should raise PluginError - payload = ResourcePreFetchPayload(uri="file://test.txt") - with pytest.raises(PluginError): - await plugin.invoke_hook(ResourceHookType.RESOURCE_PRE_FETCH, payload, context) - - # Test resource_post_fetch with empty content - should raise PluginError - resource_content = ResourceContent(type="resource", id="123", uri="file://test.txt", text="content") - payload = ResourcePostFetchPayload(uri="file://test.txt", content=resource_content) - with pytest.raises(PluginError): - await plugin.invoke_hook(ResourceHookType.RESOURCE_POST_FETCH, payload, context) - - await plugin.shutdown() - - -@pytest.mark.asyncio -async def test_get_plugin_config_no_content(): - """Test __get_plugin_config returns None when no content.""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - # Set up session mock - mock_session = AsyncMock() - plugin._session = mock_session - - # Mock empty content response - mock_session.call_tool = AsyncMock() - mock_session.call_tool.return_value = CallToolResult(content=[]) - - result = await plugin._ExternalPlugin__get_plugin_config() - assert result is None - - await plugin.shutdown() - - -@pytest.mark.asyncio -async def test_get_plugin_config_empty_dict(): - """Test __get_plugin_config returns None on empty config payload.""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - mock_session = AsyncMock() - plugin._session = mock_session - - mock_session.call_tool = AsyncMock() - mock_session.call_tool.return_value = CallToolResult(content=[MCPTextContent(type="text", text="{}")]) - - result = await plugin._ExternalPlugin__get_plugin_config() - assert result is None - - await plugin.shutdown() - - -@pytest.mark.asyncio -async def test_shutdown(): - """Test shutdown method calls exit_stack.aclose().""" - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - plugin_config = config.plugins[0] - plugin = ExternalPlugin(plugin_config) - - # Mock the exit stack - mock_exit_stack = AsyncMock() - plugin._exit_stack = mock_exit_stack - - await plugin.shutdown() - mock_exit_stack.aclose.assert_called_once() - - -def test_mcp_config_env_rejected_for_http(): - """STDIO-only env should be rejected for HTTP transports.""" - with pytest.raises(ValueError, match="script/cmd/env/cwd are only valid for STDIO transport"): - MCPClientConfig( - proto=TransportType.STREAMABLEHTTP, - url="http://localhost:8000/mcp", - env={"PLUGINS_CONFIG_PATH": "plugins/config.yaml"}, - ) - - -def test_mcp_config_env_accepts_stdio(): - """STDIO env overrides are accepted for STDIO transports.""" - cfg = MCPClientConfig( - proto=TransportType.STDIO, - script="cpex/framework/external/mcp/server/runtime.py", - env={"PLUGINS_CONFIG_PATH": "plugins/config.yaml"}, - ) - assert cfg.env is not None - - -def test_mcp_config_cwd_invalid(): - """STDIO cwd must be a valid directory.""" - with pytest.raises(ValueError, match="MCP stdio cwd"): - MCPClientConfig( - proto=TransportType.STDIO, - script="cpex/framework/external/mcp/server/runtime.py", - cwd="/path/to/nowhere", - ) - - -def test_mcp_config_cwd_valid(): - """STDIO cwd accepts existing directories and returns canonical path.""" - cfg = MCPClientConfig( - proto=TransportType.STDIO, - script="cpex/framework/external/mcp/server/runtime.py", - cwd=".", - ) - # cwd is resolved to canonical absolute path - assert os.path.isabs(cfg.cwd) - assert os.path.isdir(cfg.cwd) - - -def test_mcp_config_uds_invalid_transport(tmp_path): - """UDS is only valid for streamable HTTP.""" - uds_path = str(tmp_path / "mcp.sock") - with pytest.raises(ValueError, match="uds is only valid for STREAMABLEHTTP transport"): - MCPClientConfig( - proto=TransportType.STDIO, - script="cpex/framework/external/mcp/server/runtime.py", - uds=uds_path, - ) - - -def test_mcp_config_uds_accepts_streamable_http(tmp_path): - """UDS is accepted for streamable HTTP and returns canonical path.""" - uds_path = str(tmp_path / "mcp.sock") - cfg = MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url="http://localhost/mcp", uds=uds_path) - # uds is resolved to canonical absolute path - assert os.path.isabs(cfg.uds) - assert cfg.uds.endswith("mcp.sock") - - -def test_mcp_config_uds_tls_rejected(tmp_path): - """UDS should not allow TLS configuration.""" - uds_path = str(tmp_path / "mcp.sock") - with pytest.raises(ValueError, match="TLS configuration is not supported for Unix domain sockets"): - MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url="http://localhost/mcp", uds=uds_path, tls={}) diff --git a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py deleted file mode 100644 index d3b43f1d..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py +++ /dev/null @@ -1,902 +0,0 @@ -# -*- coding: utf-8 -*- -"""Coverage tests for cpex.framework.external.mcp.client.""" - -# Standard -import asyncio -from contextlib import AsyncExitStack -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import httpx -import orjson -import pytest -from mcp.types import TextContent - -# First-Party -from cpex.framework.base import PluginRef -from cpex.framework.errors import PluginError -from cpex.framework.external.mcp.client import ExternalHookRef, ExternalPlugin -from cpex.framework.models import ( - GlobalContext, - MCPClientConfig, - MCPClientTLSConfig, - PluginConfig, - PluginContext, - PluginResult, - TransportType, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_http_config(url: str = "http://localhost:9999/mcp", **overrides) -> PluginConfig: - defaults = dict( - name="ext_plugin", - kind="external", - version="1.0.0", - hooks=["prompt_pre_fetch"], - mcp=MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url=url), - ) - defaults.update(overrides) - return PluginConfig(**defaults) - - -def _make_stdio_config(**overrides) -> PluginConfig: - defaults = dict( - name="ext_stdio_plugin", - kind="external", - version="1.0.0", - hooks=["prompt_pre_fetch"], - mcp=MCPClientConfig(proto=TransportType.STDIO, cmd=["python", "-m", "server"]), - ) - defaults.update(overrides) - return PluginConfig(**defaults) - - -def _make_plugin(config: PluginConfig | None = None) -> ExternalPlugin: - with patch("cpex.framework.external.mcp.client.asyncio.current_task", return_value=None): - return ExternalPlugin(config or _make_http_config()) - - -# =========================================================================== -# ExternalHookRef -# =========================================================================== - - -class TestExternalHookRef: - @pytest.mark.asyncio - async def test_success(self): - plugin = _make_plugin() - ref = PluginRef(plugin) - hook_ref = ExternalHookRef("prompt_pre_fetch", ref) - assert hook_ref.name == "prompt_pre_fetch" - assert hook_ref.plugin_ref is ref - assert hook_ref.hook is not None - - @pytest.mark.asyncio - async def test_not_external_raises(self): - from cpex.framework.base import Plugin - - config = PluginConfig(name="basic", kind="test.Plugin", version="1.0", hooks=["hook"]) - plugin = Plugin(config) - ref = PluginRef(plugin) - with pytest.raises(PluginError, match="is not an external plugin"): - ExternalHookRef("hook", ref) - - -# =========================================================================== -# invoke_hook -# =========================================================================== - - -class TestInvokeHook: - @pytest.mark.asyncio - async def test_no_result_type_raises(self): - plugin = _make_plugin() - plugin._session = AsyncMock() - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = None - with pytest.raises(PluginError, match="not registered"): - await plugin.invoke_hook("unknown_hook", MagicMock(), MagicMock()) - - @pytest.mark.asyncio - async def test_no_session_raises(self): - plugin = _make_plugin() - plugin._session = None - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - with pytest.raises(PluginError, match="session not initialized"): - await plugin.invoke_hook("prompt_pre_fetch", MagicMock(), MagicMock()) - - @pytest.mark.asyncio - async def test_context_update(self): - plugin = _make_plugin() - session = AsyncMock() - plugin._session = session - - result_data = { - "context": { - "state": {"key": "val"}, - "metadata": {"mk": "mv"}, - "global_context": {"request_id": "1", "state": {"gs": "gv"}}, - }, - "result": {"continue_processing": True}, - } - text_content = TextContent(type="text", text=orjson.dumps(result_data).decode()) - call_result = MagicMock() - call_result.content = [text_content] - session.call_tool.return_value = call_result - - ctx = PluginContext(global_context=GlobalContext(request_id="1")) - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - result = await plugin.invoke_hook("prompt_pre_fetch", MagicMock(), ctx) - - assert result.continue_processing is True - assert ctx.state == {"key": "val"} - assert ctx.metadata == {"mk": "mv"} - assert ctx.global_context.state == {"gs": "gv"} - - @pytest.mark.asyncio - async def test_json_decode_error(self): - plugin = _make_plugin() - session = AsyncMock() - plugin._session = session - - text_content = TextContent(type="text", text="not-valid-json{{{") - call_result = MagicMock() - call_result.content = [text_content] - session.call_tool.return_value = call_result - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - with pytest.raises(PluginError, match="Error trying to decode json"): - await plugin.invoke_hook("prompt_pre_fetch", MagicMock(), MagicMock()) - - @pytest.mark.asyncio - async def test_error_response(self): - plugin = _make_plugin() - session = AsyncMock() - plugin._session = session - - result_data = {"error": {"message": "bad", "plugin_name": "ext_plugin"}} - text_content = TextContent(type="text", text=orjson.dumps(result_data).decode()) - call_result = MagicMock() - call_result.content = [text_content] - session.call_tool.return_value = call_result - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - with pytest.raises(PluginError, match="bad"): - await plugin.invoke_hook("prompt_pre_fetch", MagicMock(), MagicMock()) - - -# =========================================================================== -# HTTP connection retry -# =========================================================================== - - -class TestConnectHTTP: - @pytest.mark.asyncio - async def test_retry_then_success(self): - plugin = _make_plugin() - call_count = 0 - - class MockCtx: - async def __aenter__(self): - nonlocal call_count - call_count += 1 - if call_count < 3: - raise ConnectionError("refused") - read = AsyncMock() - write = AsyncMock() - get_session_id = MagicMock(return_value="sid") - return read, write, get_session_id - - async def __aexit__(self, *args): - pass - - def mock_streamable(*args, **kwargs): - return MockCtx() - - mock_session = AsyncMock() - mock_session.initialize = AsyncMock() - list_tools_result = MagicMock() - list_tools_result.tools = [] - mock_session.list_tools = AsyncMock(return_value=list_tools_result) - - with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", side_effect=mock_streamable), - patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), - patch("cpex.framework.external.mcp.client.asyncio.sleep", new_callable=AsyncMock), - ): - plugin._exit_stack = AsyncExitStack() - await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") - - @pytest.mark.asyncio - async def test_all_retries_fail(self): - plugin = _make_plugin() - - class MockCtx: - async def __aenter__(self): - raise ConnectionError("refused") - - async def __aexit__(self, *args): - pass - - def mock_streamable(*args, **kwargs): - return MockCtx() - - with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", side_effect=mock_streamable), - patch("cpex.framework.external.mcp.client.asyncio.sleep", new_callable=AsyncMock), - ): - plugin._exit_stack = AsyncExitStack() - with pytest.raises(PluginError, match="connection failed after 3 attempts"): - await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") - - -# =========================================================================== -# Shutdown -# =========================================================================== - - -class TestShutdown: - @pytest.mark.asyncio - async def test_stdio_cleanup(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_task = AsyncMock() - plugin._stdio_stop = AsyncMock() - plugin._stdio_stop.set = MagicMock() - plugin._stdio_ready = MagicMock() - plugin._stdio_exit_stack = MagicMock() - plugin._stdio_error = None - plugin._stdio = MagicMock() - plugin._write = MagicMock() - plugin._session = MagicMock() - plugin._exit_stack = AsyncMock() - - await plugin.shutdown() - - assert plugin._stdio_task is None - assert plugin._stdio_ready is None - assert plugin._stdio_stop is None - assert plugin._session is None - - @pytest.mark.asyncio - async def test_stdio_error_during_shutdown(self): - plugin = _make_plugin(_make_stdio_config()) - - async def raise_error(): - raise RuntimeError("shutdown fail") - - plugin._stdio_task = AsyncMock(side_effect=raise_error) - plugin._stdio_stop = AsyncMock() - plugin._stdio_stop.set = MagicMock() - plugin._stdio_ready = MagicMock() - plugin._stdio_exit_stack = MagicMock() - plugin._stdio = MagicMock() - plugin._write = MagicMock() - plugin._session = MagicMock() - plugin._exit_stack = AsyncMock() - - # Should not raise - await plugin.shutdown() - assert plugin._stdio_task is None - - -# =========================================================================== -# Terminate HTTP session -# =========================================================================== - - -class TestTerminateHTTPSession: - @pytest.mark.asyncio - async def test_no_session_id_returns(self): - plugin = _make_plugin() - plugin._session_id = None - # Should return early without error - await plugin._ExternalPlugin__terminate_http_session() - - @pytest.mark.asyncio - async def test_with_factory(self): - plugin = _make_plugin() - plugin._session_id = "test-session" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - plugin._http_client_factory = MagicMock(return_value=mock_client) - - await plugin._ExternalPlugin__terminate_http_session() - mock_client.delete.assert_called_once() - - @pytest.mark.asyncio - async def test_no_factory(self): - plugin = _make_plugin() - plugin._session_id = "test-session" - plugin._http_client_factory = None - - with patch("cpex.framework.external.mcp.client.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_client - - await plugin._ExternalPlugin__terminate_http_session() - mock_client.delete.assert_called_once() - - -# =========================================================================== -# Command Resolution -# =========================================================================== - - -class TestResolveStdioCommand: - def test_sh_script(self, tmp_path): - script = tmp_path / "server.sh" - script.write_text("#!/bin/sh\necho hello") - plugin = _make_plugin(_make_stdio_config()) - cmd, args = plugin._ExternalPlugin__resolve_stdio_command(str(script), None, None) - assert cmd == "sh" - assert args == [str(script)] - - def test_invalid_cmd(self): - plugin = _make_plugin(_make_stdio_config()) - with pytest.raises(PluginError, match="non-empty list"): - plugin._ExternalPlugin__resolve_stdio_command(None, [""], None) - - def test_no_script_no_cmd(self): - plugin = _make_plugin(_make_stdio_config()) - with pytest.raises(PluginError, match="requires script or cmd"): - plugin._ExternalPlugin__resolve_stdio_command(None, None, None) - - def test_cmd_success(self): - plugin = _make_plugin(_make_stdio_config()) - cmd, args = plugin._ExternalPlugin__resolve_stdio_command(None, ["python", "-m", "server"], None) - assert cmd == "python" - assert args == ["-m", "server"] - - def test_nonexistent_script(self, tmp_path): - plugin = _make_plugin(_make_stdio_config()) - with pytest.raises(PluginError, match="does not exist"): - plugin._ExternalPlugin__resolve_stdio_command(str(tmp_path / "nonexistent.py"), None, None) - - def test_python_script(self, tmp_path): - script = tmp_path / "server.py" - script.write_text("print('hello')") - plugin = _make_plugin(_make_stdio_config()) - import sys - - cmd, args = plugin._ExternalPlugin__resolve_stdio_command(str(script), None, None) - assert cmd == sys.executable - assert args == [str(script)] - - def test_relative_script_resolved_with_cwd(self, tmp_path): - """Cover the cwd + relative path resolution branch.""" - script = tmp_path / "server.py" - script.write_text("print('hello')") - plugin = _make_plugin(_make_stdio_config()) - import sys - - cmd, args = plugin._ExternalPlugin__resolve_stdio_command("server.py", None, str(tmp_path)) - assert cmd == sys.executable - assert args == [str(script)] - - def test_non_executable_non_script_raises(self, tmp_path): - """Non-.py/.sh files must be executable.""" - script = tmp_path / "server.bin" - script.write_text("data") - script.chmod(0o644) - plugin = _make_plugin(_make_stdio_config()) - with pytest.raises(PluginError, match="must be executable"): - plugin._ExternalPlugin__resolve_stdio_command(str(script), None, None) - - def test_executable_non_script_returns_path(self, tmp_path): - """Executable non-.py/.sh file should be executed directly.""" - script = tmp_path / "server.bin" - script.write_text("data") - script.chmod(0o755) - plugin = _make_plugin(_make_stdio_config()) - cmd, args = plugin._ExternalPlugin__resolve_stdio_command(str(script), None, None) - assert cmd == str(script) - assert args == [] - - -# =========================================================================== -# UDS + TLS warning -# =========================================================================== - - -class TestResolveStdioCommandSync: - """Tests that don't need async context — use patched ExternalPlugin.""" - - def test_build_stdio_env(self): - """Test __build_stdio_env merges env correctly.""" - with patch("cpex.framework.external.mcp.client.asyncio.current_task", return_value=None): - plugin = _make_plugin(_make_stdio_config()) - env = plugin._ExternalPlugin__build_stdio_env({"MY_VAR": "val"}) - assert env["MY_VAR"] == "val" - assert "PATH" in env # should include current env - - -class TestConnectHTTPUDS: - @pytest.mark.asyncio - async def test_uds_with_tls_warning(self, caplog): - """When uds_path is set with TLS config, warn that TLS is ignored.""" - # Cannot create config with both uds and tls due to model validator, - # so we test the internal code path by setting attributes after creation - config = _make_http_config() - plugin = _make_plugin(config) - plugin._config.mcp.uds = "/tmp/test.sock" - # Set a TLS config directly (bypassing validation) - tls_config = MagicMock() - object.__setattr__(plugin._config.mcp, "tls", tls_config) - - class FailCtx: - async def __aenter__(self): - raise ConnectionError("fail") - - async def __aexit__(self, *args): - pass - - # Mock the connection to fail immediately so we can check the warning - with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", return_value=FailCtx()), - patch("cpex.framework.external.mcp.client.asyncio.sleep", new_callable=AsyncMock), - pytest.raises(PluginError), - ): - plugin._exit_stack = AsyncExitStack() - await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") - - assert any("TLS configuration is ignored" in r.message for r in caplog.records) - - -# =========================================================================== -# initialize -# =========================================================================== - - -class TestInitialize: - @pytest.mark.asyncio - async def test_initialize_requires_mcp_section(self): - # Use a non-external kind so model validators don't reject missing transport config. - config = PluginConfig(name="no_mcp", kind="internal", version="1.0.0", hooks=["prompt_pre_fetch"]) - plugin = _make_plugin(config) - with pytest.raises(PluginError, match="mcp section must be defined"): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_stdio_requires_script_or_cmd(self): - config = _make_stdio_config() - plugin = _make_plugin(config) - # Break invariants after model validation so we can exercise initialize() checks. - object.__setattr__(plugin._config.mcp, "cmd", None) - object.__setattr__(plugin._config.mcp, "script", None) - with pytest.raises(PluginError, match="STDIO transport requires script or cmd"): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_streamablehttp_requires_url(self): - config = _make_http_config() - plugin = _make_plugin(config) - object.__setattr__(plugin._config.mcp, "url", None) - with pytest.raises(PluginError, match="STREAMABLEHTTP transport requires url"): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_skips_connect_for_other_transports(self): - """TransportType.SSE isn't handled by initialize() connect logic (falls through).""" - config = PluginConfig( - name="sse_plugin", - kind="external", - version="1.0.0", - hooks=["prompt_pre_fetch"], - mcp=MCPClientConfig(proto=TransportType.SSE, url="http://localhost:9999/mcp"), - ) - plugin = _make_plugin(config) - with patch.object(plugin, "shutdown", new=AsyncMock()): - with pytest.raises(PluginError, match="session not initialized"): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_merges_remote_config(self): - """Happy path: connect + fetch remote config, then merge with local config.""" - config = _make_http_config() - plugin = _make_plugin(config) - - remote_config = PluginConfig( - name=config.name, - kind="external", - version="2.0.0", - description="remote description", - hooks=config.hooks, - mcp=config.mcp, - ) - - with ( - patch.object(plugin, "_ExternalPlugin__connect_to_http_server", new=AsyncMock()), - patch.object(plugin, "_ExternalPlugin__get_plugin_config", new=AsyncMock(return_value=remote_config)), - ): - await plugin.initialize() - - assert plugin.config.description == "remote description" - # Local config values override remote config (remote is used as base defaults). - assert plugin.config.version == "1.0.0" - - @pytest.mark.asyncio - async def test_initialize_when_config_missing_triggers_shutdown_even_if_shutdown_fails(self): - config = _make_http_config() - plugin = _make_plugin(config) - - with ( - patch.object(plugin, "_ExternalPlugin__connect_to_http_server", new=AsyncMock()), - patch.object(plugin, "_ExternalPlugin__get_plugin_config", new=AsyncMock(return_value=None)), - patch.object(plugin, "shutdown", new=AsyncMock(side_effect=RuntimeError("shutdown fail"))), - ): - with pytest.raises(PluginError, match="Unable to retrieve configuration"): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_generic_exception_converted_to_pluginerror_and_shutdown_errors_swallowed(self): - config = _make_http_config() - plugin = _make_plugin(config) - - with ( - patch.object(plugin, "_ExternalPlugin__connect_to_http_server", new=AsyncMock()), - patch.object(plugin, "_ExternalPlugin__get_plugin_config", new=AsyncMock(side_effect=ValueError("boom"))), - patch.object(plugin, "shutdown", new=AsyncMock(side_effect=RuntimeError("shutdown fail"))), - ): - with pytest.raises(PluginError): - await plugin.initialize() - - -# =========================================================================== -# Additional MCP client branch coverage -# =========================================================================== - - -class TestHTTPClientFactory: - @pytest.mark.asyncio - async def test_http_client_factory_includes_headers_auth_and_tls(self): - config = _make_http_config() - # Provide a TLS config so create_ssl_context path is exercised. - object.__setattr__(config.mcp, "tls", MCPClientTLSConfig(verify=True, check_hostname=True)) - plugin = _make_plugin(config) - - class OkCtx: - async def __aenter__(self): - read = AsyncMock() - write = AsyncMock() - get_session_id = MagicMock(return_value="sid") - return read, write, get_session_id - - async def __aexit__(self, *args): - return False - - mock_session = AsyncMock() - list_tools_result = MagicMock() - list_tools_result.tools = [] - mock_session.list_tools = AsyncMock(return_value=list_tools_result) - - mock_http_settings = MagicMock() - mock_http_settings.httpx_max_connections = 10 - mock_http_settings.httpx_max_keepalive_connections = 5 - mock_http_settings.httpx_keepalive_expiry = 30 - mock_http_settings.httpx_connect_timeout = 5.0 - mock_http_settings.httpx_read_timeout = 120.0 - mock_http_settings.httpx_write_timeout = 30.0 - mock_http_settings.httpx_pool_timeout = 10.0 - mock_http_settings.skip_ssl_verify = False - - with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", return_value=OkCtx()), - patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), - patch("cpex.framework.external.mcp.client.create_ssl_context", return_value="sslctx"), - patch("cpex.framework.external.mcp.client.httpx.AsyncClient") as mock_httpx, - patch("cpex.framework.external.mcp.client.get_http_client_settings", return_value=mock_http_settings), - ): - plugin._exit_stack = AsyncExitStack() - await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") - - assert plugin._http_client_factory is not None - plugin._http_client_factory(headers={"x-test": "1"}, auth=httpx.BasicAuth("u", "p")) - - assert mock_httpx.call_count >= 1 - _, kwargs = mock_httpx.call_args - assert kwargs["headers"]["x-test"] == "1" - assert kwargs["auth"] is not None - assert kwargs["verify"] == "sslctx" - - -class TestGetPluginConfig: - @pytest.mark.asyncio - async def test_get_plugin_config_requires_session(self): - plugin = _make_plugin() - plugin._session = None - with pytest.raises(PluginError, match="session not initialized"): - await plugin._ExternalPlugin__get_plugin_config() - - @pytest.mark.asyncio - async def test_get_plugin_config_skips_non_text_content(self): - plugin = _make_plugin() - session = AsyncMock() - plugin._session = session - - conf = PluginConfig( - name=plugin.name, kind="external", version="1.0.0", hooks=["prompt_pre_fetch"], mcp=_make_http_config().mcp - ) - text_content = TextContent(type="text", text=orjson.dumps(conf.model_dump()).decode()) - call_result = MagicMock() - call_result.content = [MagicMock(), text_content] - session.call_tool.return_value = call_result - - loaded = await plugin._ExternalPlugin__get_plugin_config() - assert loaded is not None - assert loaded.name == plugin.name - - @pytest.mark.asyncio - async def test_get_plugin_config_errors_converted(self): - plugin = _make_plugin() - session = AsyncMock() - session.call_tool = AsyncMock(side_effect=RuntimeError("nope")) - plugin._session = session - with pytest.raises(PluginError): - await plugin._ExternalPlugin__get_plugin_config() - - -class TestInvokeHookMoreBranches: - @pytest.mark.asyncio - async def test_invoke_hook_skips_non_text_content(self): - plugin = _make_plugin() - session = AsyncMock() - plugin._session = session - - result_data = {"result": {"continue_processing": True}} - text_content = TextContent(type="text", text=orjson.dumps(result_data).decode()) - call_result = MagicMock() - call_result.content = [MagicMock(), text_content] - session.call_tool.return_value = call_result - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - result = await plugin.invoke_hook( - "prompt_pre_fetch", MagicMock(), PluginContext(global_context=GlobalContext(request_id="1")) - ) - - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_invoke_hook_call_tool_exception_is_converted(self): - plugin = _make_plugin() - session = AsyncMock() - session.call_tool = AsyncMock(side_effect=RuntimeError("boom")) - plugin._session = session - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - with pytest.raises(PluginError): - await plugin.invoke_hook( - "prompt_pre_fetch", MagicMock(), PluginContext(global_context=GlobalContext(request_id="1")) - ) - - @pytest.mark.asyncio - async def test_invoke_hook_context_only_then_result_loops(self): - """Exercise the branch where a TextContent payload contains only CONTEXT and the loop continues.""" - plugin = _make_plugin() - session = AsyncMock() - plugin._session = session - - ctx_only = { - "context": {"state": {"k": "v"}, "metadata": {}, "global_context": {"request_id": "1", "state": {}}} - } - res = {"result": {"continue_processing": True}} - call_result = MagicMock() - call_result.content = [ - TextContent(type="text", text=orjson.dumps(ctx_only).decode()), - TextContent(type="text", text=orjson.dumps(res).decode()), - ] - session.call_tool.return_value = call_result - - ctx = PluginContext(global_context=GlobalContext(request_id="1")) - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_reg: - mock_reg.return_value.get_result_type.return_value = PluginResult - result = await plugin.invoke_hook("prompt_pre_fetch", MagicMock(), ctx) - - assert result.continue_processing is True - assert ctx.state == {"k": "v"} - - -class TestShutdownMoreBranches: - @pytest.mark.asyncio - async def test_shutdown_stdio_task_without_stop_event(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_task = AsyncMock() - plugin._stdio_stop = None - plugin._stdio_ready = MagicMock() - plugin._exit_stack = AsyncMock() - plugin._session = MagicMock() - - await plugin.shutdown() - assert plugin._stdio_task is None - - @pytest.mark.asyncio - async def test_shutdown_cleans_stdio_state_even_when_proto_is_http(self): - """Weird-but-possible: stdio task exists but config says STREAMABLEHTTP.""" - plugin = _make_plugin(_make_http_config()) - plugin._stdio_task = AsyncMock() - plugin._stdio_stop = AsyncMock() - plugin._stdio_stop.set = MagicMock() - plugin._stdio_ready = MagicMock() - plugin._exit_stack = None - plugin._session_id = None - - await plugin.shutdown() - assert plugin._stdio_task is None - - -class TestRunStdioSessionBranches: - @pytest.mark.asyncio - async def test_run_stdio_session_error_before_exit_stack_sets_ready(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_ready = asyncio.Event() - plugin._stdio_ready.set() # already set -> skip set() in finally - plugin._stdio_stop = None - plugin._stdio_error = None - - with patch.object(plugin, "_ExternalPlugin__resolve_stdio_command", side_effect=ValueError("bad")): - await plugin._ExternalPlugin__run_stdio_session(None, ["python"], None, None) - - assert plugin._stdio_error is not None - - @pytest.mark.asyncio - async def test_run_stdio_session_error_after_exit_stack_closes_exit_stack(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_ready = asyncio.Event() - plugin._stdio_stop = None - plugin._stdio_error = None - - class FailCtx: - async def __aenter__(self): - raise RuntimeError("enter failed") - - async def __aexit__(self, *args): - return False - - with patch("cpex.framework.external.mcp.client.stdio_client", return_value=FailCtx()): - await plugin._ExternalPlugin__run_stdio_session(None, ["python"], None, None) - - assert plugin._stdio_error is not None - - @pytest.mark.asyncio - async def test_run_stdio_session_waits_on_stop_and_skips_close_when_exit_stack_cleared(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_ready = asyncio.Event() - plugin._stdio_stop = asyncio.Event() - plugin._stdio_error = None - - after_list_tools = asyncio.Event() - - async def _list_tools_side_effect(): - after_list_tools.set() - res = MagicMock() - res.tools = [] - return res - - mock_session = AsyncMock() - mock_session.list_tools = AsyncMock(side_effect=_list_tools_side_effect) - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - - class OkCtx: - async def __aenter__(self): - return AsyncMock(), AsyncMock() - - async def __aexit__(self, *args): - return False - - async def flip_exit_stack(): - await after_list_tools.wait() - # Let __run_stdio_session reach the wait() point. - await asyncio.sleep(0) - plugin._stdio_exit_stack = None - plugin._stdio_stop.set() - - flip_task = asyncio.create_task(flip_exit_stack()) - try: - with ( - patch.object(plugin, "_ExternalPlugin__resolve_stdio_command", return_value=("python", ["-c", "pass"])), - patch("cpex.framework.external.mcp.client.stdio_client", return_value=OkCtx()), - patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), - ): - await plugin._ExternalPlugin__run_stdio_session(None, ["python"], None, None) - finally: - await flip_task - - @pytest.mark.asyncio - async def test_run_stdio_session_success_without_stop_event(self): - """Cover the branch where _stdio_stop is falsy and we skip waiting.""" - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_ready = asyncio.Event() - plugin._stdio_stop = None - plugin._stdio_error = None - - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) - - class OkCtx: - async def __aenter__(self): - return AsyncMock(), AsyncMock() - - async def __aexit__(self, *args): - return False - - with ( - patch.object(plugin, "_ExternalPlugin__resolve_stdio_command", return_value=("python", ["-c", "pass"])), - patch("cpex.framework.external.mcp.client.stdio_client", return_value=OkCtx()), - patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), - ): - await plugin._ExternalPlugin__run_stdio_session(None, ["python"], None, None) - - assert plugin._stdio_error is None - - -class TestConnectStdioBranches: - @pytest.mark.asyncio - async def test_connect_to_stdio_server_reuses_existing_events(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_ready = asyncio.Event() - plugin._stdio_ready.set() - plugin._stdio_stop = asyncio.Event() - - # Patch the worker coroutine, but let create_task schedule it to avoid un-awaited coroutine warnings. - with patch.object(plugin, "_ExternalPlugin__run_stdio_session", new=AsyncMock()): - await plugin._ExternalPlugin__connect_to_stdio_server(None, ["python", "-c", "pass"], None, None) - - assert plugin._stdio_task is not None - - @pytest.mark.asyncio - async def test_connect_to_stdio_server_create_task_error_converted(self): - plugin = _make_plugin(_make_stdio_config()) - plugin._stdio_ready = asyncio.Event() - plugin._stdio_ready.set() - plugin._stdio_stop = asyncio.Event() - - def _raise(coro, *args, **kwargs): - coro.close() - raise RuntimeError("boom") - - with patch("cpex.framework.external.mcp.client.asyncio.create_task", side_effect=_raise): - with pytest.raises(PluginError): - await plugin._ExternalPlugin__connect_to_stdio_server(None, ["python", "-c", "pass"], None, None) - - -class TestConnectHTTPMoreBranches: - @pytest.mark.asyncio - async def test_connect_http_range_empty_exits_loop(self): - plugin = _make_plugin() - plugin._exit_stack = AsyncExitStack() - with patch("cpex.framework.external.mcp.client.range", return_value=[]): - # No attempts performed; should just fall through and return. - await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") - - -class TestTerminateHTTPSessionErrors: - @pytest.mark.asyncio - async def test_terminate_http_session_delete_failure_is_swallowed(self, caplog): - plugin = _make_plugin() - plugin._session_id = "sid" - - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.delete = AsyncMock(side_effect=RuntimeError("delete failed")) - - plugin._http_client_factory = MagicMock(return_value=mock_client) - with caplog.at_level("DEBUG", logger="cpex.framework.external.mcp.client"): - await plugin._ExternalPlugin__terminate_http_session() - - assert any("Failed to terminate streamable HTTP session" in r.message for r in caplog.records) diff --git a/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py b/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py deleted file mode 100644 index 5de38f28..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py +++ /dev/null @@ -1,431 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/test_client_reconnect.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Unit tests for MCP external plugin client reconnection logic. -Ported from ContextForge main (#3639). -""" - -# Standard -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ToolPreInvokePayload -from cpex.framework.errors import PluginError -from cpex.framework.external.mcp.client import ExternalPlugin -from cpex.framework.models import ( - GlobalContext, - MCPClientConfig, - PluginConfig, - PluginContext, - PluginErrorModel, - TransportType, -) - - -@pytest.fixture -def mock_http_plugin_config(): - return PluginConfig( - name="TestHTTPPlugin", - kind="external", - hooks=["tool_pre_invoke"], - mcp=MCPClientConfig( - proto=TransportType.STREAMABLEHTTP, - url="http://localhost:9000/mcp", - reconnect_attempts=3, - reconnect_delay=0.1, - ), - ) - - -@pytest.fixture -def mock_stdio_plugin_config(tmp_path): - script_path = tmp_path / "server.py" - script_path.write_text("# dummy server") - return PluginConfig( - name="TestSTDIOPlugin", - kind="external", - hooks=["tool_pre_invoke"], - mcp=MCPClientConfig( - proto=TransportType.STDIO, - script=str(script_path), - reconnect_attempts=2, - reconnect_delay=0.05, - ), - ) - - -@pytest.fixture -def mock_plugin_context(): - return PluginContext( - global_context=GlobalContext(request_id="test-request-123"), - state={}, - metadata={}, - ) - - -class TestReconnectConfiguration: - @pytest.mark.asyncio - async def test_reconnect_config_loaded_from_mcp_config(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - plugin._reconnect_attempts = mock_http_plugin_config.mcp.reconnect_attempts - plugin._reconnect_delay = mock_http_plugin_config.mcp.reconnect_delay - assert plugin._reconnect_attempts == 3 - assert plugin._reconnect_delay == 0.1 - - @pytest.mark.asyncio - async def test_reconnect_config_defaults(self): - config = PluginConfig( - name="TestPlugin", - kind="external", - hooks=["tool_pre_invoke"], - mcp=MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url="http://localhost:9000/mcp"), - ) - plugin = ExternalPlugin(config) - assert plugin._reconnect_attempts == 3 - assert plugin._reconnect_delay == 0.1 - - -class TestCleanupSession: - @pytest.mark.asyncio - async def test_cleanup_session_resets_all_state(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - plugin._session = MagicMock() - plugin._http = MagicMock() - plugin._write = MagicMock() - plugin._stdio = MagicMock() - plugin._get_session_id = MagicMock() - plugin._session_id = "test-session-id" - plugin._exit_stack = AsyncMock() - plugin._stdio_exit_stack = AsyncMock() - - await plugin._cleanup_session() - - assert plugin._session is None - assert plugin._http is None - assert plugin._write is None - assert plugin._stdio is None - assert plugin._get_session_id is None - assert plugin._session_id is None - - @pytest.mark.asyncio - async def test_cleanup_session_closes_exit_stacks(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_exit_stack = AsyncMock() - mock_stdio_exit_stack = AsyncMock() - plugin._exit_stack = mock_exit_stack - plugin._stdio_exit_stack = mock_stdio_exit_stack - - await plugin._cleanup_session() - - mock_exit_stack.aclose.assert_called_once() - mock_stdio_exit_stack.aclose.assert_called_once() - - -class TestReconnectSession: - @pytest.mark.asyncio - async def test_reconnect_http_success_on_first_attempt(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - plugin._reconnect_attempts = 3 - plugin._reconnect_delay = 0.1 - - with patch.object(plugin, "_cleanup_session", new_callable=AsyncMock) as mock_cleanup: - with patch.object( - plugin, "_ExternalPlugin__connect_to_http_server", new_callable=AsyncMock - ) as mock_connect: - await plugin._reconnect_session() - mock_cleanup.assert_called_once() - mock_connect.assert_called_once_with(mock_http_plugin_config.mcp.url) - - @pytest.mark.asyncio - async def test_reconnect_stdio_success_on_first_attempt(self, mock_stdio_plugin_config): - plugin = ExternalPlugin(mock_stdio_plugin_config) - plugin._reconnect_attempts = 2 - plugin._reconnect_delay = 0.05 - - with patch.object(plugin, "_cleanup_session", new_callable=AsyncMock) as mock_cleanup: - with patch.object( - plugin, "_ExternalPlugin__connect_to_stdio_server", new_callable=AsyncMock - ) as mock_connect: - await plugin._reconnect_session() - mock_cleanup.assert_called_once() - mock_connect.assert_called_once_with( - mock_stdio_plugin_config.mcp.script, - mock_stdio_plugin_config.mcp.cmd, - mock_stdio_plugin_config.mcp.env, - mock_stdio_plugin_config.mcp.cwd, - ) - - @pytest.mark.asyncio - async def test_reconnect_linear_backoff(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - plugin._reconnect_attempts = 3 - plugin._reconnect_delay = 0.1 - - call_count = 0 - - async def mock_connect_fail(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count < 3: - raise ConnectionError("Connection failed") - - with patch.object(plugin, "_cleanup_session", new_callable=AsyncMock): - with patch.object( - plugin, "_ExternalPlugin__connect_to_http_server", new_callable=AsyncMock, side_effect=mock_connect_fail - ): - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - await plugin._reconnect_session() - assert mock_sleep.call_count == 2 - calls = mock_sleep.call_args_list - assert calls[0][0][0] == 0.1 # 0.1 * 1 - assert calls[1][0][0] == 0.2 # 0.1 * 2 - - @pytest.mark.asyncio - async def test_reconnect_attempts_exhausted(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - plugin._reconnect_attempts = 2 - plugin._reconnect_delay = 0.01 - - with patch.object(plugin, "_cleanup_session", new_callable=AsyncMock): - with patch.object( - plugin, - "_ExternalPlugin__connect_to_http_server", - new_callable=AsyncMock, - side_effect=ConnectionError("Connection failed"), - ): - with patch("asyncio.sleep", new_callable=AsyncMock): - with pytest.raises(PluginError) as exc_info: - await plugin._reconnect_session() - assert "Failed to reconnect" in str(exc_info.value.error.message) - assert "2 attempts" in str(exc_info.value.error.message) - - -class TestInvokeHookWithReconnection: - @pytest.mark.asyncio - async def test_invoke_hook_reconnects_on_mcp_error(self, mock_http_plugin_config, mock_plugin_context): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_session = AsyncMock() - plugin._session = mock_session - - from mcp import McpError - from mcp.types import ErrorData - - call_count = 0 - - async def mock_call_tool(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise McpError(ErrorData(code=-1, message="Connection lost")) - from mcp.types import CallToolResult, TextContent - - return CallToolResult(content=[TextContent(type="text", text='{"result": {"name": "test", "args": {}}}')]) - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - with patch.object(plugin, "_reconnect_session", new_callable=AsyncMock) as mock_reconnect: - payload = ToolPreInvokePayload(name="test", args={}) - result = await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - mock_reconnect.assert_called_once() - assert result is not None - - @pytest.mark.asyncio - async def test_invoke_hook_reconnects_on_session_terminated(self, mock_http_plugin_config, mock_plugin_context): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_session = AsyncMock() - plugin._session = mock_session - - call_count = 0 - - async def mock_call_tool(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise PluginError(error=PluginErrorModel(message="Session terminated", plugin_name="TestHTTPPlugin")) - from mcp.types import CallToolResult, TextContent - - return CallToolResult(content=[TextContent(type="text", text='{"result": {"name": "test", "args": {}}}')]) - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - with patch.object(plugin, "_reconnect_session", new_callable=AsyncMock) as mock_reconnect: - payload = ToolPreInvokePayload(name="test", args={}) - result = await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - mock_reconnect.assert_called_once() - assert result is not None - - @pytest.mark.asyncio - async def test_invoke_hook_no_reconnect_on_other_plugin_errors(self, mock_http_plugin_config, mock_plugin_context): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_session = AsyncMock() - plugin._session = mock_session - - async def mock_call_tool(*args, **kwargs): - raise PluginError(error=PluginErrorModel(message="Invalid argument", plugin_name="TestHTTPPlugin")) - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - with patch.object(plugin, "_reconnect_session", new_callable=AsyncMock) as mock_reconnect: - payload = ToolPreInvokePayload(name="test", args={}) - with pytest.raises(PluginError) as exc_info: - await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - mock_reconnect.assert_not_called() - assert "Invalid argument" in str(exc_info.value.error.message) - - @pytest.mark.asyncio - async def test_invoke_hook_reconnect_failure_raises_original_error( - self, mock_http_plugin_config, mock_plugin_context - ): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_session = AsyncMock() - plugin._session = mock_session - - from mcp import McpError - from mcp.types import ErrorData - - async def mock_call_tool(*args, **kwargs): - raise McpError(ErrorData(code=-1, message="Connection lost")) - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - with patch.object( - plugin, - "_reconnect_session", - new_callable=AsyncMock, - side_effect=PluginError( - error=PluginErrorModel(message="Reconnection failed", plugin_name="TestHTTPPlugin") - ), - ): - payload = ToolPreInvokePayload(name="test", args={}) - with pytest.raises(PluginError) as exc_info: - await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - error_msg = str(exc_info.value.error.message) - assert "Reconnection failed" in error_msg - - @pytest.mark.asyncio - async def test_invoke_hook_session_terminated_reconnect_failure_reraises_original( - self, mock_http_plugin_config, mock_plugin_context - ): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_session = AsyncMock() - plugin._session = mock_session - - async def mock_call_tool(*args, **kwargs): - raise PluginError( - error=PluginErrorModel(message="Session terminated by server", plugin_name="TestHTTPPlugin") - ) - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - with patch.object( - plugin, - "_reconnect_session", - new_callable=AsyncMock, - side_effect=PluginError( - error=PluginErrorModel(message="Reconnection failed", plugin_name="TestHTTPPlugin") - ), - ): - payload = ToolPreInvokePayload(name="test", args={}) - with pytest.raises(PluginError) as exc_info: - await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - assert "Reconnection failed" in str(exc_info.value.error.message) - - @pytest.mark.asyncio - async def test_invoke_hook_generic_exception_converted_to_plugin_error( - self, mock_http_plugin_config, mock_plugin_context - ): - plugin = ExternalPlugin(mock_http_plugin_config) - mock_session = AsyncMock() - plugin._session = mock_session - - async def mock_call_tool(*args, **kwargs): - raise RuntimeError("Unexpected failure") - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - payload = ToolPreInvokePayload(name="test", args={}) - with pytest.raises(PluginError) as exc_info: - await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - assert "Unexpected failure" in str(exc_info.value.error.message) - - -class TestCleanupSessionStdio: - @pytest.mark.asyncio - async def test_cleanup_session_stops_stdio_task(self, mock_stdio_plugin_config): - plugin = ExternalPlugin(mock_stdio_plugin_config) - stop_event = asyncio.Event() - ready_event = asyncio.Event() - ready_event.set() - - plugin._stdio_stop = stop_event - plugin._stdio_ready = ready_event - plugin._stdio_error = None - plugin._session = MagicMock() - - async def mock_stdio_runner(): - await stop_event.wait() - - plugin._stdio_task = asyncio.create_task(mock_stdio_runner()) - plugin._exit_stack = AsyncMock() - - await plugin._cleanup_session() - - assert plugin._stdio_task is None - assert plugin._stdio_ready is None - assert plugin._stdio_stop is None - assert plugin._stdio_error is None - assert plugin._session is None - - @pytest.mark.asyncio - async def test_cleanup_session_handles_stdio_task_exception(self, mock_stdio_plugin_config): - plugin = ExternalPlugin(mock_stdio_plugin_config) - stop_event = asyncio.Event() - plugin._stdio_stop = stop_event - plugin._stdio_ready = asyncio.Event() - plugin._stdio_error = None - plugin._session = MagicMock() - - async def failing_stdio_runner(): - await stop_event.wait() - raise RuntimeError("stdio crash") - - plugin._stdio_task = asyncio.create_task(failing_stdio_runner()) - plugin._exit_stack = AsyncMock() - - await plugin._cleanup_session() - - assert plugin._stdio_task is None - assert plugin._stdio_ready is None - assert plugin._stdio_stop is None - - @pytest.mark.asyncio - async def test_cleanup_session_no_stdio_task_skips_task_cleanup(self, mock_http_plugin_config): - plugin = ExternalPlugin(mock_http_plugin_config) - plugin._session = MagicMock() - plugin._exit_stack = AsyncMock() - plugin._stdio_task = None - plugin._stdio_exit_stack = None - - await plugin._cleanup_session() - - assert plugin._session is None - assert plugin._stdio_ready is None - assert plugin._stdio_stop is None diff --git a/tests/unit/cpex/framework/external/mcp/test_client_stdio.py b/tests/unit/cpex/framework/external/mcp/test_client_stdio.py deleted file mode 100644 index f6deed91..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_client_stdio.py +++ /dev/null @@ -1,336 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/test_client_stdio.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Tests for external client on stdio. -""" - -# Standard -import json -import os -import re -import sys -from contextlib import AsyncExitStack -from typing import Optional - -import pytest - -# Third-Party -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -# First-Party -from cpex.framework import ( - ConfigLoader, - GlobalContext, - PluginConfig, - PluginContext, - PluginError, - PluginLoader, - PluginManager, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ResourceHookType, - ResourcePostFetchPayload, - ResourcePreFetchPayload, - ToolHookType, - ToolPostInvokePayload, - ToolPreInvokePayload, -) -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, ResourceContent, Role, TextContent -from tests.unit.cpex.fixtures.plugins.search_replace import SearchReplaceConfig - - -@pytest.mark.asyncio -async def test_client_load_stdio(): - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - os.environ["PYTHONPATH"] = "." - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin.yaml") - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"text": "That was innovative!"}) - result = await plugin.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - prompt, - PluginContext(global_context=GlobalContext(request_id="1", server_id="2")), - ) - assert result.violation - assert result.violation.reason == "Prompt not allowed" - assert result.violation.description == "A deny word was found in the prompt" - assert result.violation.code == "deny" - config = plugin.config - assert config.name == "DenyListPlugin" - assert config.description == "A plugin that implements a deny list filter." - assert config.priority == 100 - assert config.kind == "external" - await plugin.shutdown() - del os.environ["PLUGINS_CONFIG_PATH"] - del os.environ["PYTHONPATH"] - - -@pytest.mark.asyncio -async def test_client_load_stdio_overrides(): - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - os.environ["PYTHONPATH"] = "." - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_overrides.yaml") - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"text": "That was innovative!"}) - result = await plugin.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - prompt, - PluginContext(global_context=GlobalContext(request_id="1", server_id="2")), - ) - assert result.violation - assert result.violation.reason == "Prompt not allowed" - assert result.violation.description == "A deny word was found in the prompt" - assert result.violation.code == "deny" - config = plugin.config - assert config.name == "DenyListPlugin" - assert config.description == "a different configuration." - assert config.priority == 150 - assert config.hooks[0] == "prompt_pre_fetch" - assert config.hooks[1] == "prompt_post_fetch" - assert config.kind == "external" - await plugin.shutdown() - del os.environ["PLUGINS_CONFIG_PATH"] - del os.environ["PYTHONPATH"] - - -@pytest.mark.asyncio -async def test_client_load_stdio_post_prompt(): - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - os.environ["PYTHONPATH"] = "." - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_regex.yaml") - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - assert result.modified_payload.args["user"] == "What a yikesshow!" - config = plugin.config - assert config.name == "ReplaceBadWordsPlugin" - assert config.description == "A plugin for finding and replacing words." - assert config.priority == 150 - assert config.kind == "external" - - message = Message(content=TextContent(type="text", text="What the crud?"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result = await plugin.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload_result, context=context) - assert len(result.modified_payload.result.messages) == 1 - assert result.modified_payload.result.messages[0].content.text == "What the yikes?" - await plugin.shutdown() - await loader.shutdown() - del os.environ["PLUGINS_CONFIG_PATH"] - del os.environ["PYTHONPATH"] - - -@pytest.mark.asyncio -async def test_client_get_plugin_configs(): - session: Optional[ClientSession] = None - exit_stack = AsyncExitStack() - current_env = os.environ.copy() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml" - current_env["PYTHONPATH"] = "." - server_params = StdioServerParameters( - command=sys.executable, args=["cpex/framework/external/mcp/server/runtime.py"], env=current_env - ) - - stdio_transport = await exit_stack.enter_async_context(stdio_client(server_params)) - stdio, write = stdio_transport - session = await exit_stack.enter_async_context(ClientSession(stdio, write)) - - await session.initialize() - all_configs = [] - configs = await session.call_tool("get_plugin_configs", {}) - for content in configs.content: - confs = json.loads(content.text) - if isinstance(confs, dict): - if "name" in confs: - all_configs.append(PluginConfig.model_validate(confs)) - else: - for config_data in confs.values(): - all_configs.append(PluginConfig.model_validate(config_data)) - else: - for c in confs: - all_configs.append(PluginConfig.model_validate(c)) - await exit_stack.aclose() - assert all_configs[0].name == "SynonymsPlugin" - assert all_configs[0].kind == "plugins.search_replace.SearchReplacePlugin" - assert all_configs[0].description == "A plugin for finding and replacing synonyms." - assert all_configs[0].version == "0.1" - assert all_configs[0].author == "ContextForge Team" - assert all_configs[0].hooks[0] == "prompt_pre_fetch" - assert all_configs[0].hooks[1] == "prompt_post_fetch" - assert all_configs[0].config - srconfig = SearchReplaceConfig.model_validate(all_configs[0].config) - assert len(srconfig.words) == 2 - assert srconfig.words[0].search == "happy" - assert srconfig.words[0].replace == "gleeful" - assert all_configs[1].name == "ReplaceBadWordsPlugin" - assert all_configs[1].kind == "plugins.search_replace.SearchReplacePlugin" - assert all_configs[1].description == "A plugin for finding and replacing words." - assert all_configs[1].version == "0.1" - assert all_configs[1].author == "ContextForge Team" - assert all_configs[1].hooks[0] == "prompt_pre_fetch" - assert all_configs[1].hooks[1] == "prompt_post_fetch" - assert all_configs[1].config - srconfig = SearchReplaceConfig.model_validate(all_configs[1].config) - assert srconfig.words[0].search == "crap" - assert srconfig.words[0].replace == "crud" - assert len(all_configs) == 2 - - -@pytest.mark.asyncio -async def test_hooks(): - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin_passthrough.yaml" - os.environ["PYTHONPATH"] = "." - pm = PluginManager() - if pm.initialized: - await pm.shutdown() - plugin_manager = PluginManager( - config="tests/unit/cpex/fixtures/configs/valid_stdio_external_plugin_passthrough.yaml" - ) - await plugin_manager.initialize() - payload = PromptPrehookPayload( - prompt_id="test_prompt", name="test_prompt", args={"arg0": "This is a crap argument"} - ) - global_context = GlobalContext(request_id="1") - result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - # Assert expected behaviors - assert result.continue_processing - """Test prompt post hook across all registered plugins.""" - # Customize payload for testing - message = Message(content=TextContent(type="text", text="prompt"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - payload = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload, global_context) - # Assert expected behaviors - assert result.continue_processing - """Test tool pre hook across all registered plugins.""" - # Customize payload for testing - payload = ToolPreInvokePayload(name="test_prompt", args={"arg0": "This is an argument"}) - result, _ = await plugin_manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, payload, global_context) - # Assert expected behaviors - assert result.continue_processing - """Test tool post hook across all registered plugins.""" - # Customize payload for testing - payload = ToolPostInvokePayload(name="test_tool", result={"output0": "output value"}) - result, _ = await plugin_manager.invoke_hook(ToolHookType.TOOL_POST_INVOKE, payload, global_context) - # Assert expected behaviors - assert result.continue_processing - - payload = ResourcePreFetchPayload(uri="file:///data.txt") - result, _ = await plugin_manager.invoke_hook(ResourceHookType.RESOURCE_PRE_FETCH, payload, global_context) - # Assert expected behaviors - assert result.continue_processing - - content = ResourceContent(type="resource", id="123", uri="file:///data.txt", text="Hello World") - payload = ResourcePostFetchPayload(uri="file:///data.txt", content=content) - result, _ = await plugin_manager.invoke_hook(ResourceHookType.RESOURCE_POST_FETCH, payload, global_context) - # Assert expected behaviors - assert result.continue_processing - await plugin_manager.shutdown() - - -@pytest.mark.asyncio -async def test_errors(): - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/error_plugin.yaml" - os.environ["PYTHONPATH"] = "." - plugin_manager = PluginManager(config="tests/unit/cpex/fixtures/configs/error_stdio_external_plugin.yaml") - await plugin_manager.initialize() - payload = PromptPrehookPayload( - prompt_id="test_prompt", name="test_prompt", args={"arg0": "This is a crap argument"} - ) - global_context = GlobalContext(request_id="1") - escaped_regex = re.escape("ValueError('Sadly! Prompt prefetch is broken!')") - with pytest.raises(PluginError, match=escaped_regex): - await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - await plugin_manager.shutdown() - - -@pytest.mark.asyncio -async def test_shared_context_across_pre_post_hooks_multi_plugins(): - os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/context_multiplugins.yaml" - os.environ["PYTHONPATH"] = "." - manager = PluginManager("./tests/unit/cpex/fixtures/configs/context_stdio_external_plugins.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke with transformation - use correct tool name from config - tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - assert len(contexts) == 2 - ctxs = [contexts[key] for key in contexts.keys()] - assert len(ctxs) == 2 - context1 = ctxs[0] - context2 = ctxs[1] - assert context1.state - assert "key2" in context1.state - assert "cp2key1" not in context1.state - assert context1.state["key2"] == "value2" - assert len(context1.state) == 1 - assert context1.global_context.state["globkey1"] == "globvalue1" - assert "gcp2globkey1" not in context1.global_context.state - assert len(context1.global_context.state) - assert not context1.global_context.metadata - - assert context2.state - assert len(context2.state) == 1 - assert "cp2key1" in context2.state - assert "key2" not in context2.state - assert context2.global_context.state["globkey1"] == "globvalue1" - assert context2.global_context.state["gcp2globkey1"] == "gcp2globvalue1" - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is None - # Test tool post-invoke with transformation - tool_result_payload = ToolPostInvokePayload( - name="test_tool", result={"output": "Result was bad", "status": "wrong format"} - ) - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_result_payload, global_context=global_context, local_contexts=contexts - ) - - ctxs = [contexts[key] for key in contexts.keys()] - assert len(ctxs) == 2 - context1 = ctxs[0] - context2 = ctxs[1] - assert context1.state - assert len(context1.state) == 2 - assert context1.state["key3"] == "value3" - assert context1.state["key2"] == "value2" - assert "cp2key1" not in context1.state - assert "cp2key2" not in context1.state - assert context1.global_context.state["globkey1"] == "globvalue1" - # gcp2globkey1 is set by ContextPlugin2 (AUDIT); it is not merged to global_context - assert "gcp2globkey1" not in context1.global_context.state - assert "gcp2globkey2" not in context1.global_context.state - assert context1.global_context.state["globkey2"] == "globvalue2" - - assert context2.global_context.state["globkey1"] == "globvalue1" - # gcp2globkey1 is not propagated from the first call since AUDIT does not merge global state - assert "gcp2globkey1" not in context2.global_context.state - assert context2.global_context.state["gcp2globkey2"] == "gcp2globvalue2" - assert context2.global_context.state["globkey2"] == "globvalue2" - - assert "key3" not in context2.state - assert "key2" not in context2.state - assert "cp2key1" in context2.state - - await manager.shutdown() diff --git a/tests/unit/cpex/framework/external/mcp/test_client_streamable_http.py b/tests/unit/cpex/framework/external/mcp/test_client_streamable_http.py deleted file mode 100644 index 85685c46..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_client_streamable_http.py +++ /dev/null @@ -1,331 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/test_client_streamable_http.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Tests for external client on streamable http. -""" - -# Standard -import os -import socket -import stat -import subprocess -import sys -import time - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - ConfigLoader, - GlobalContext, - PluginContext, - PluginLoader, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, -) -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, Role, TextContent - - -def _wait_for_port(host: str, port: int, timeout: float = 10.0, proc: subprocess.Popen | None = None) -> None: - """Wait until a TCP port is accepting connections.""" - start = time.time() - while time.time() - start < timeout: - if proc and proc.poll() is not None: - output = "" - if proc.stdout: - output = proc.stdout.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Server exited before port opened. Output:\n{output}") - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(0.5) - if sock.connect_ex((host, port)) == 0: - return - time.sleep(0.1) - raise RuntimeError(f"Timed out waiting for {host}:{port}") - - -def _wait_for_socket(path: str, timeout: float = 10.0, proc: subprocess.Popen | None = None) -> None: - """Wait until a unix domain socket path exists.""" - start = time.time() - while time.time() - start < timeout: - if proc and proc.poll() is not None: - output = "" - if proc.stdout: - output = proc.stdout.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Server exited before socket created. Output:\n{output}") - try: - if os.path.exists(path) and stat.S_ISSOCK(os.stat(path).st_mode): - return - except FileNotFoundError: - pass - time.sleep(0.1) - raise RuntimeError(f"Timed out waiting for socket: {path}") - - -def _get_free_port() -> int: - """Get an available TCP port for testing.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -@pytest.fixture(autouse=True) -def _disable_ssrf_for_local_tests(monkeypatch): - """Disable SSRF IP blocking so tests can use 127.0.0.1 for real local servers.""" - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "false") - from cpex.framework.settings import settings # pylint: disable=import-outside-toplevel - - settings.cache_clear() - yield - settings.cache_clear() - - -@pytest.fixture -def server_proc(): - current_env = os.environ.copy() - port = _get_free_port() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "http" - current_env["PLUGINS_SERVER_HOST"] = "127.0.0.1" - current_env["PLUGINS_SERVER_PORT"] = str(port) - current_env["PLUGINS_SSRF_PROTECTION_ENABLED"] = "false" - # Start the server as a subprocess - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/mcp/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_port("127.0.0.1", port, proc=server_proc) - yield server_proc, port - server_proc.terminate() - server_proc.wait(timeout=3) # Wait for the subprocess to complete - except subprocess.TimeoutExpired: - server_proc.kill() # Force kill if timeout occurs - server_proc.wait(timeout=3) - - -@pytest.mark.asyncio -async def test_client_load_streamable_http(server_proc): - server_proc, port = server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml") - config.plugins[0].mcp.url = f"http://127.0.0.1:{port}/mcp" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - assert result.modified_payload.args["user"] == "What a yikesshow!" - config = plugin.config - assert config.name == "ReplaceBadWordsPlugin" - assert config.description == "A plugin for finding and replacing words." - assert config.priority == 150 - assert config.kind == "external" - message = Message(content=TextContent(type="text", text="What the crud?"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result = await plugin.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload_result, context) - assert len(result.modified_payload.result.messages) == 1 - assert result.modified_payload.result.messages[0].content.text == "What the yikes?" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.fixture -def server_proc1(): - current_env = os.environ.copy() - port = _get_free_port() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "http" - current_env["PLUGINS_SERVER_HOST"] = "127.0.0.1" - current_env["PLUGINS_SERVER_PORT"] = str(port) - # Start the server as a subprocess - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/mcp/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_port("127.0.0.1", port, proc=server_proc) - yield server_proc, port - server_proc.terminate() - server_proc.wait(timeout=3) # Wait for the subprocess to complete - except subprocess.TimeoutExpired: - server_proc.kill() # Force kill if timeout occurs - server_proc.wait(timeout=3) - - -@pytest.mark.asyncio -async def test_client_load_strhttp_overrides(server_proc1): - server_proc1, port = server_proc1 - assert not server_proc1.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_overrides.yaml") - config.plugins[0].mcp.url = f"http://127.0.0.1:{port}/mcp" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"text": "That was innovative!"}) - result = await plugin.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - prompt, - PluginContext(global_context=GlobalContext(request_id="1", server_id="2")), - ) - assert result.violation - assert result.violation.reason == "Prompt not allowed" - assert result.violation.description == "A deny word was found in the prompt" - assert result.violation.code == "deny" - config = plugin.config - assert config.name == "DenyListPlugin" - assert config.description == "a different configuration." - assert config.priority == 150 - assert config.hooks[0] == "prompt_pre_fetch" - assert config.hooks[1] == "prompt_post_fetch" - assert config.kind == "external" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.fixture -def server_proc2(): - current_env = os.environ.copy() - port = _get_free_port() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "http" - current_env["PLUGINS_SERVER_HOST"] = "127.0.0.1" - current_env["PLUGINS_SERVER_PORT"] = str(port) - # Start the server as a subprocess - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/mcp/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_port("127.0.0.1", port, proc=server_proc) - yield server_proc, port - server_proc.terminate() - server_proc.wait(timeout=3) # Wait for the subprocess to complete - except subprocess.TimeoutExpired: - server_proc.kill() # Force kill if timeout occurs - server_proc.wait(timeout=3) - - -@pytest.fixture -def server_proc_uds(): - # Use /tmp directly to keep socket path short (macOS has ~104 char limit for UDS paths) - # pytest's tmp_path creates paths like /var/folders/.../pytest-xxx/test_xxx0/ which are too long - import uuid - - short_id = uuid.uuid4().hex[:8] - uds_path = f"/tmp/mcp-{short_id}.sock" - - current_env = os.environ.copy() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "http" - current_env["PLUGINS_SERVER_UDS"] = uds_path - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/mcp/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_socket(uds_path, proc=server_proc) - # Verify the server is actually accepting connections on the UDS - _start = time.time() - while time.time() - _start < 10: - try: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as _s: - _s.settimeout(0.5) - _s.connect(uds_path) - break - except (ConnectionRefusedError, OSError): - time.sleep(0.1) - else: - raise RuntimeError(f"Server never accepted connections on {uds_path}") - yield server_proc, uds_path - server_proc.terminate() - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait(timeout=3) - finally: - # Clean up the socket file - if os.path.exists(uds_path): - os.unlink(uds_path) - - -@pytest.mark.asyncio -async def test_client_load_strhttp_post_prompt(server_proc2): - server_proc2, port = server_proc2 - assert not server_proc2.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml") - config.plugins[0].mcp.url = f"http://127.0.0.1:{port}/mcp" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - assert result.modified_payload.args["user"] == "What a yikesshow!" - config = plugin.config - assert config.name == "ReplaceBadWordsPlugin" - assert config.description == "A plugin for finding and replacing words." - assert config.priority == 150 - assert config.kind == "external" - - message = Message(content=TextContent(type="text", text="What the crud?"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result = await plugin.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload_result, context) - assert len(result.modified_payload.result.messages) == 1 - assert result.modified_payload.result.messages[0].content.text == "What the yikes?" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_client_load_streamable_http_uds(server_proc_uds): - server_proc, uds_path = server_proc_uds - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_strhttp_external_plugin_regex.yaml") - config.plugins[0].mcp.uds = uds_path - config.plugins[0].mcp.url = "http://localhost/mcp" - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - assert result.modified_payload.args["user"] == "What a yikesshow!" - finally: - await plugin.shutdown() - await loader.shutdown() diff --git a/tests/unit/cpex/framework/external/mcp/test_tls_utils.py b/tests/unit/cpex/framework/external/mcp/test_tls_utils.py deleted file mode 100644 index ca1dcada..00000000 --- a/tests/unit/cpex/framework/external/mcp/test_tls_utils.py +++ /dev/null @@ -1,371 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/mcp/test_tls_utils.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Additional unit tests for TLS utilities to improve code coverage. -""" - -# Standard -import ssl -from unittest.mock import patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.errors import PluginError -from cpex.framework.external.mcp.tls_utils import create_ssl_context -from cpex.framework.models import MCPClientTLSConfig - - -class TestCreateSSLContextBasicConfig: - """Tests for basic SSL context configuration.""" - - def test_create_ssl_context_minimal_config(self): - """Test creating SSL context with minimal configuration.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "MinimalPlugin") - - assert ssl_context is not None - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is True - assert ssl_context.minimum_version == ssl.TLSVersion.TLSv1_2 - - def test_create_ssl_context_verify_disabled(self): - """Test creating SSL context with verification disabled.""" - tls_config = MCPClientTLSConfig(verify=False, check_hostname=False) - - ssl_context = create_ssl_context(tls_config, "InsecurePlugin") - - assert ssl_context is not None - assert ssl_context.verify_mode == ssl.CERT_NONE - assert ssl_context.check_hostname is False - - def test_create_ssl_context_with_ca_bundle(self, tmp_path): - """Test creating SSL context with CA bundle.""" - # Create a temporary CA file - ca_file = tmp_path / "ca.pem" - ca_file.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") - - tls_config = MCPClientTLSConfig(ca_bundle=str(ca_file), verify=True) - - # Will fail to load the invalid cert but we're testing the path is used - with pytest.raises(PluginError): - create_ssl_context(tls_config, "TestPlugin") - - def test_create_ssl_context_hostname_check_disabled(self): - """Test creating SSL context with hostname checking disabled but verify enabled.""" - tls_config = MCPClientTLSConfig(verify=True, check_hostname=False) - - ssl_context = create_ssl_context(tls_config, "NoHostnameCheckPlugin") - - assert ssl_context is not None - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is False - - -class TestCreateSSLContextClientCertificates: - """Tests for SSL context with client certificates (mTLS).""" - - def test_create_ssl_context_with_client_cert(self, tmp_path): - """Test creating SSL context with client certificate.""" - cert_file = tmp_path / "client.crt" - key_file = tmp_path / "client.key" - cert_file.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") - key_file.write_text("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----") - - tls_config = MCPClientTLSConfig(certfile=str(cert_file), keyfile=str(key_file), verify=False) - - # Will fail to load the invalid cert but we're testing the path is used - with pytest.raises(PluginError): - create_ssl_context(tls_config, "mTLSPlugin") - - def test_create_ssl_context_with_cert_no_key(self, tmp_path): - """Test creating SSL context with cert but no key (should use same file).""" - cert_file = tmp_path / "combined.pem" - cert_file.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") - - tls_config = MCPClientTLSConfig(certfile=str(cert_file), keyfile=None, verify=False) - - # Will fail to load the invalid cert - with pytest.raises(PluginError): - create_ssl_context(tls_config, "CombinedPEMPlugin") - - def test_create_ssl_context_with_encrypted_key(self, tmp_path): - """Test creating SSL context with encrypted private key.""" - cert_file = tmp_path / "client.crt" - key_file = tmp_path / "client.key" - cert_file.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") - key_file.write_text("-----BEGIN ENCRYPTED PRIVATE KEY-----\ntest\n-----END ENCRYPTED PRIVATE KEY-----") - - tls_config = MCPClientTLSConfig( - certfile=str(cert_file), keyfile=str(key_file), keyfile_password="secret123", verify=False - ) - - # Will fail to load the invalid cert - with pytest.raises(PluginError): - create_ssl_context(tls_config, "EncryptedKeyPlugin") - - -class TestCreateSSLContextSecuritySettings: - """Tests for SSL context security settings.""" - - def test_ssl_context_enforces_tls_1_2_minimum(self): - """Test that SSL context enforces TLS 1.2 as minimum version.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "SecurePlugin") - - assert ssl_context.minimum_version == ssl.TLSVersion.TLSv1_2 - # Ensure weak protocols are not allowed - assert ssl_context.minimum_version > ssl.TLSVersion.TLSv1_1 - - def test_ssl_context_uses_default_context_security(self): - """Test that ssl.create_default_context() security settings are preserved.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "DefaultSecurityPlugin") - - # create_default_context() sets secure defaults - # Verify CERT_REQUIRED is set (from create_default_context) - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - - -class TestCreateSSLContextErrorHandling: - """Tests for error handling in create_ssl_context.""" - - def test_create_ssl_context_invalid_ca_bundle(self, tmp_path): - """Test that invalid CA bundle content raises PluginError.""" - # Create a file with invalid certificate content - ca_file = tmp_path / "invalid_ca.pem" - ca_file.write_text("INVALID CERTIFICATE CONTENT") - - tls_config = MCPClientTLSConfig(ca_bundle=str(ca_file), verify=True) - - with pytest.raises(PluginError) as exc_info: - create_ssl_context(tls_config, "InvalidCAPlugin") - - assert "InvalidCAPlugin" in str(exc_info.value) - assert "Failed to configure SSL context" in str(exc_info.value) - - def test_create_ssl_context_invalid_client_cert(self, tmp_path): - """Test that invalid client certificate content raises PluginError.""" - # Create files with invalid certificate/key content - cert_file = tmp_path / "invalid_cert.pem" - key_file = tmp_path / "invalid_key.pem" - cert_file.write_text("INVALID CERT") - key_file.write_text("INVALID KEY") - - tls_config = MCPClientTLSConfig(certfile=str(cert_file), keyfile=str(key_file), verify=False) - - with pytest.raises(PluginError) as exc_info: - create_ssl_context(tls_config, "InvalidCertPlugin") - - assert "InvalidCertPlugin" in str(exc_info.value) - assert "Failed to configure SSL context" in str(exc_info.value) - - def test_create_ssl_context_exception_includes_plugin_name(self, tmp_path): - """Test that PluginError includes the plugin name in error details.""" - # Create a file with invalid content - ca_file = tmp_path / "bad_ca.pem" - ca_file.write_text("BAD CONTENT") - - tls_config = MCPClientTLSConfig(ca_bundle=str(ca_file), verify=True) - - with pytest.raises(PluginError) as exc_info: - create_ssl_context(tls_config, "MyTestPlugin") - - error = exc_info.value - assert error.error.plugin_name == "MyTestPlugin" - assert "MyTestPlugin" in error.error.message - - def test_create_ssl_context_generic_exception_handling(self): - """Test that any exception during SSL context creation is caught and wrapped.""" - tls_config = MCPClientTLSConfig(verify=True) - - with patch("ssl.create_default_context") as mock_create: - mock_create.side_effect = RuntimeError("SSL initialization failed") - - with pytest.raises(PluginError) as exc_info: - create_ssl_context(tls_config, "FailingPlugin") - - assert "Failed to configure SSL context" in str(exc_info.value) - assert "FailingPlugin" in str(exc_info.value) - - -class TestCreateSSLContextLogging: - """Tests for logging in create_ssl_context.""" - - def test_create_ssl_context_logs_verification_disabled(self): - """Test that disabling verification logs a warning.""" - tls_config = MCPClientTLSConfig(verify=False) - - with patch("cpex.framework.external.mcp.tls_utils.logger") as mock_logger: - create_ssl_context(tls_config, "InsecurePlugin") - - # Should log warning about disabled verification - assert mock_logger.warning.called - warning_calls = [call for call in mock_logger.warning.call_args_list] - assert any("verification disabled" in str(call).lower() for call in warning_calls) - - def test_create_ssl_context_logs_hostname_check_disabled(self): - """Test that disabling hostname checking logs a warning.""" - tls_config = MCPClientTLSConfig(verify=True, check_hostname=False) - - with patch("cpex.framework.external.mcp.tls_utils.logger") as mock_logger: - create_ssl_context(tls_config, "NoHostnamePlugin") - - # Should log warning about disabled hostname verification - assert mock_logger.warning.called - warning_calls = [call for call in mock_logger.warning.call_args_list] - assert any("hostname" in str(call).lower() for call in warning_calls) - - def test_create_ssl_context_logs_mtls_enabled(self, tmp_path): - """Test that mTLS configuration is logged.""" - cert_file = tmp_path / "client.crt" - key_file = tmp_path / "client.key" - # Create minimal valid-looking PEM files - cert_file.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") - key_file.write_text("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----") - - tls_config = MCPClientTLSConfig(certfile=str(cert_file), keyfile=str(key_file), verify=False) - - with patch("cpex.framework.external.mcp.tls_utils.logger"): - # Will fail but we can check if debug logging was attempted - try: - create_ssl_context(tls_config, "mTLSPlugin") - except PluginError: - pass # Expected to fail with invalid cert - - # Should have attempted to log debug message about mTLS - # (even though it failed) - - def test_create_ssl_context_logs_debug_info(self): - """Test that SSL context configuration is logged at debug level.""" - tls_config = MCPClientTLSConfig(verify=True) - - with patch("cpex.framework.external.mcp.tls_utils.logger") as mock_logger: - create_ssl_context(tls_config, "DebugPlugin") - - # Should log debug message with context details - assert mock_logger.debug.called - - def test_create_ssl_context_logs_error_on_failure(self, tmp_path): - """Test that errors are logged.""" - # Create a file with invalid content - ca_file = tmp_path / "bad.pem" - ca_file.write_text("INVALID") - - tls_config = MCPClientTLSConfig(ca_bundle=str(ca_file), verify=True) - - with patch("cpex.framework.external.mcp.tls_utils.logger") as mock_logger: - with pytest.raises(PluginError): - create_ssl_context(tls_config, "ErrorPlugin") - - # Should log error - assert mock_logger.error.called - - -class TestCreateSSLContextIntegration: - """Integration tests for create_ssl_context.""" - - def test_create_ssl_context_production_like_config(self): - """Test creating SSL context with production-like configuration.""" - tls_config = MCPClientTLSConfig(verify=True, check_hostname=True) - - ssl_context = create_ssl_context(tls_config, "ProductionPlugin") - - # Verify all security features are enabled - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is True - assert ssl_context.minimum_version == ssl.TLSVersion.TLSv1_2 - - def test_create_ssl_context_development_config(self): - """Test creating SSL context with development/testing configuration.""" - tls_config = MCPClientTLSConfig(verify=False, check_hostname=False) - - ssl_context = create_ssl_context(tls_config, "DevPlugin") - - # Verify security is relaxed - assert ssl_context.verify_mode == ssl.CERT_NONE - assert ssl_context.check_hostname is False - - def test_create_ssl_context_mixed_security_config(self): - """Test creating SSL context with mixed security settings.""" - # Verify enabled but hostname check disabled - tls_config = MCPClientTLSConfig(verify=True, check_hostname=False) - - ssl_context = create_ssl_context(tls_config, "MixedPlugin") - - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is False - - -class TestCreateSSLContextCompliance: - """Tests for SSL context compliance with security standards.""" - - def test_ssl_context_meets_tls_requirements(self): - """Test that SSL context meets modern TLS requirements.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "CompliancePlugin") - - # Modern security requirements - assert ssl_context.minimum_version >= ssl.TLSVersion.TLSv1_2 - assert ssl_context.verify_mode in [ssl.CERT_REQUIRED, ssl.CERT_OPTIONAL] - - def test_ssl_context_default_is_secure(self): - """Test that default SSL context configuration is secure.""" - tls_config = MCPClientTLSConfig() # All defaults - - ssl_context = create_ssl_context(tls_config, "DefaultPlugin") - - # Defaults should be secure - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - assert ssl_context.check_hostname is True - assert ssl_context.minimum_version == ssl.TLSVersion.TLSv1_2 - - -class TestCreateSSLContextEdgeCases: - """Tests for edge cases in create_ssl_context.""" - - def test_create_ssl_context_empty_plugin_name(self): - """Test creating SSL context with empty plugin name.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "") - - assert ssl_context is not None - - def test_create_ssl_context_special_chars_in_plugin_name(self): - """Test creating SSL context with special characters in plugin name.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "Plugin-Name_123!@#") - - assert ssl_context is not None - - def test_create_ssl_context_unicode_plugin_name(self): - """Test creating SSL context with unicode characters in plugin name.""" - tls_config = MCPClientTLSConfig(verify=True) - - ssl_context = create_ssl_context(tls_config, "プラグイン") - - assert ssl_context is not None - - def test_create_ssl_context_verify_true_hostname_false(self): - """Test the combination of verify=True with check_hostname=False.""" - tls_config = MCPClientTLSConfig(verify=True, check_hostname=False) - - with patch("cpex.framework.external.mcp.tls_utils.logger") as mock_logger: - ssl_context = create_ssl_context(tls_config, "PartialSecurityPlugin") - - # Should warn about hostname verification being disabled - assert mock_logger.warning.called - # Should still have CERT_REQUIRED - assert ssl_context.verify_mode == ssl.CERT_REQUIRED - # But hostname check should be disabled - assert ssl_context.check_hostname is False diff --git a/tests/unit/cpex/framework/external/test_proto_convert.py b/tests/unit/cpex/framework/external/test_proto_convert.py deleted file mode 100644 index e866ae01..00000000 --- a/tests/unit/cpex/framework/external/test_proto_convert.py +++ /dev/null @@ -1,566 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/test_proto_convert.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for proto_convert conversion utilities. -Tests for Pydantic <-> Protobuf conversion functions. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import ( - GlobalContext, - PluginContext, - PluginResult, - PluginViolation, -) - -try: - from google.protobuf import json_format - from google.protobuf.struct_pb2 import Struct - - from cpex.framework.external.grpc.proto import plugin_service_pb2 - from cpex.framework.external.proto_convert import ( - proto_context_to_dict, - proto_context_to_pydantic, - proto_global_context_to_pydantic, - proto_violation_to_pydantic, - pydantic_context_to_proto, - pydantic_global_context_to_proto, - pydantic_result_to_proto_base, - pydantic_violation_to_proto, - update_pydantic_context_from_proto, - update_pydantic_result_from_proto_base, - ) - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -class TestPydanticGlobalContextToProto: - """Tests for pydantic_global_context_to_proto.""" - - def test_basic_conversion(self): - """Test basic global context conversion.""" - ctx = GlobalContext(request_id="req-1", server_id="srv-1", tenant_id="tenant-1") - proto = pydantic_global_context_to_proto(ctx) - assert proto.request_id == "req-1" - assert proto.server_id == "srv-1" - assert proto.tenant_id == "tenant-1" - - def test_user_string(self): - """Test conversion with string user field.""" - ctx = GlobalContext(request_id="req-1", user="admin@example.com") - proto = pydantic_global_context_to_proto(ctx) - assert proto.user_string == "admin@example.com" - - def test_user_dict(self): - """Test conversion with dict user field.""" - ctx = GlobalContext(request_id="req-1", user={"name": "admin", "role": "super"}) - proto = pydantic_global_context_to_proto(ctx) - user_dict = json_format.MessageToDict(proto.user_struct) - assert user_dict["name"] == "admin" - assert user_dict["role"] == "super" - - def test_user_none(self): - """Test conversion with None user field.""" - ctx = GlobalContext(request_id="req-1", user=None) - proto = pydantic_global_context_to_proto(ctx) - assert not proto.HasField("user_string") - assert not proto.HasField("user_struct") - - def test_with_metadata(self): - """Test conversion with metadata.""" - ctx = GlobalContext(request_id="req-1", metadata={"key": "value"}) - proto = pydantic_global_context_to_proto(ctx) - meta = json_format.MessageToDict(proto.metadata) - assert meta["key"] == "value" - - def test_with_state(self): - """Test conversion with state.""" - ctx = GlobalContext(request_id="req-1", state={"counter": 42}) - proto = pydantic_global_context_to_proto(ctx) - state = json_format.MessageToDict(proto.state) - assert state["counter"] == 42 - - def test_none_optional_fields(self): - """Test conversion with None optional fields.""" - ctx = GlobalContext(request_id="req-1", server_id=None, tenant_id=None) - proto = pydantic_global_context_to_proto(ctx) - assert proto.server_id == "" - assert proto.tenant_id == "" - - -class TestProtoGlobalContextToPydantic: - """Tests for proto_global_context_to_pydantic.""" - - def test_basic_conversion(self): - """Test basic proto to pydantic conversion.""" - proto = plugin_service_pb2.GlobalContext(request_id="req-1", server_id="srv-1", tenant_id="tenant-1") - ctx = proto_global_context_to_pydantic(proto) - assert ctx.request_id == "req-1" - assert ctx.server_id == "srv-1" - assert ctx.tenant_id == "tenant-1" - - def test_user_string(self): - """Test conversion with user_string field.""" - proto = plugin_service_pb2.GlobalContext(request_id="req-1") - proto.user_string = "admin@example.com" - ctx = proto_global_context_to_pydantic(proto) - assert ctx.user == "admin@example.com" - - def test_user_struct(self): - """Test conversion with user_struct field.""" - proto = plugin_service_pb2.GlobalContext(request_id="req-1") - user_struct = Struct() - json_format.ParseDict({"name": "admin", "role": "super"}, user_struct) - proto.user_struct.CopyFrom(user_struct) - ctx = proto_global_context_to_pydantic(proto) - assert ctx.user["name"] == "admin" - assert ctx.user["role"] == "super" - - def test_with_metadata(self): - """Test conversion with metadata.""" - proto = plugin_service_pb2.GlobalContext(request_id="req-1") - json_format.ParseDict({"key": "value"}, proto.metadata) - ctx = proto_global_context_to_pydantic(proto) - assert ctx.metadata["key"] == "value" - - def test_with_state(self): - """Test conversion with state.""" - proto = plugin_service_pb2.GlobalContext(request_id="req-1") - json_format.ParseDict({"counter": 42}, proto.state) - ctx = proto_global_context_to_pydantic(proto) - assert ctx.state["counter"] == 42 - - def test_empty_optional_fields(self): - """Test conversion with empty optional fields.""" - proto = plugin_service_pb2.GlobalContext(request_id="req-1") - ctx = proto_global_context_to_pydantic(proto) - assert ctx.server_id is None - assert ctx.tenant_id is None - assert ctx.user is None - assert ctx.metadata == {} - assert ctx.state == {} - - -class TestPydanticContextToProto: - """Tests for pydantic_context_to_proto.""" - - def test_basic_conversion(self): - """Test basic context conversion.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1", server_id="srv-1"), - state={"key": "value"}, - ) - proto = pydantic_context_to_proto(ctx) - assert proto.global_context.request_id == "req-1" - state = json_format.MessageToDict(proto.state) - assert state["key"] == "value" - - def test_with_metadata(self): - """Test context conversion with metadata.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1"), - metadata={"meta_key": "meta_value"}, - ) - proto = pydantic_context_to_proto(ctx) - meta = json_format.MessageToDict(proto.metadata) - assert meta["meta_key"] == "meta_value" - - def test_empty_state_and_metadata(self): - """Test context conversion with empty state and metadata.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1"), - ) - proto = pydantic_context_to_proto(ctx) - assert not proto.state.fields - assert not proto.metadata.fields - - -class TestProtoContextToPydantic: - """Tests for proto_context_to_pydantic.""" - - def test_basic_conversion(self): - """Test basic proto context to pydantic.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - proto.global_context.server_id = "srv-1" - json_format.ParseDict({"key": "value"}, proto.state) - - ctx = proto_context_to_pydantic(proto) - assert ctx.global_context.request_id == "req-1" - assert ctx.state["key"] == "value" - - def test_empty_fields(self): - """Test proto context with empty fields.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - ctx = proto_context_to_pydantic(proto) - assert ctx.state == {} - assert ctx.metadata == {} - - -class TestProtoContextToDict: - """Tests for proto_context_to_dict.""" - - def test_basic_conversion(self): - """Test basic proto context to dict.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - proto.global_context.server_id = "srv-1" - proto.global_context.tenant_id = "tenant-1" - json_format.ParseDict({"key": "value"}, proto.state) - - result = proto_context_to_dict(proto) - assert result["global_context"]["request_id"] == "req-1" - assert result["global_context"]["server_id"] == "srv-1" - assert result["state"]["key"] == "value" - - def test_with_user_string(self): - """Test proto context to dict with user string.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - proto.global_context.user_string = "admin" - result = proto_context_to_dict(proto) - assert result["global_context"]["user"] == "admin" - - def test_with_user_struct(self): - """Test proto context to dict with user struct.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - user_struct = Struct() - json_format.ParseDict({"name": "admin"}, user_struct) - proto.global_context.user_struct.CopyFrom(user_struct) - result = proto_context_to_dict(proto) - assert result["global_context"]["user"]["name"] == "admin" - - def test_empty_fields(self): - """Test proto context to dict with empty fields.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - result = proto_context_to_dict(proto) - assert result["global_context"]["user"] is None - assert result["global_context"]["metadata"] == {} - assert result["global_context"]["state"] == {} - assert result["state"] == {} - assert result["metadata"] == {} - - def test_with_metadata_and_state(self): - """Test proto context to dict with metadata and state.""" - proto = plugin_service_pb2.PluginContext() - proto.global_context.request_id = "req-1" - json_format.ParseDict({"meta": "data"}, proto.global_context.metadata) - json_format.ParseDict({"gc_state": "val"}, proto.global_context.state) - json_format.ParseDict({"ctx_state": "val2"}, proto.state) - json_format.ParseDict({"ctx_meta": "val3"}, proto.metadata) - - result = proto_context_to_dict(proto) - assert result["global_context"]["metadata"]["meta"] == "data" - assert result["global_context"]["state"]["gc_state"] == "val" - assert result["state"]["ctx_state"] == "val2" - assert result["metadata"]["ctx_meta"] == "val3" - - -class TestPydanticViolationToProto: - """Tests for pydantic_violation_to_proto.""" - - def test_basic_conversion(self): - """Test basic violation conversion.""" - violation = PluginViolation( - reason="blocked", - description="Content blocked", - code="BLOCKED", - ) - violation.plugin_name = "TestPlugin" - proto = pydantic_violation_to_proto(violation) - assert proto.reason == "blocked" - assert proto.description == "Content blocked" - assert proto.code == "BLOCKED" - assert proto.plugin_name == "TestPlugin" - - def test_with_details(self): - """Test violation conversion with details.""" - violation = PluginViolation( - reason="blocked", - description="Content blocked", - code="BLOCKED", - details={"severity": "high", "category": "security"}, - ) - proto = pydantic_violation_to_proto(violation) - details = json_format.MessageToDict(proto.details) - assert details["severity"] == "high" - - def test_with_mcp_error_code(self): - """Test violation conversion with mcp error code.""" - violation = PluginViolation( - reason="error", - description="Error occurred", - code="ERR", - mcp_error_code=-32600, - ) - proto = pydantic_violation_to_proto(violation) - assert proto.mcp_error_code == -32600 - - def test_none_optional_fields(self): - """Test violation conversion with None optional fields.""" - violation = PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - mcp_error_code=None, - ) - # plugin_name defaults to "" via PrivateAttr - proto = pydantic_violation_to_proto(violation) - assert proto.plugin_name == "" - assert proto.mcp_error_code == 0 - - -class TestProtoViolationToPydantic: - """Tests for proto_violation_to_pydantic.""" - - def test_basic_conversion(self): - """Test basic proto violation to pydantic.""" - proto = plugin_service_pb2.PluginViolation( - reason="blocked", - description="Content blocked", - code="BLOCKED", - plugin_name="TestPlugin", - mcp_error_code=-32600, - ) - violation = proto_violation_to_pydantic(proto) - assert violation.reason == "blocked" - assert violation.description == "Content blocked" - assert violation.code == "BLOCKED" - assert violation.plugin_name == "TestPlugin" - assert violation.mcp_error_code == -32600 - - def test_with_details(self): - """Test proto violation to pydantic with details.""" - proto = plugin_service_pb2.PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - ) - json_format.ParseDict({"severity": "high"}, proto.details) - violation = proto_violation_to_pydantic(proto) - assert violation.details["severity"] == "high" - - def test_empty_details(self): - """Test proto violation to pydantic with empty details.""" - proto = plugin_service_pb2.PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - ) - violation = proto_violation_to_pydantic(proto) - assert violation.details == {} - - def test_no_plugin_name(self): - """Test proto violation to pydantic without plugin_name.""" - proto = plugin_service_pb2.PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - ) - violation = proto_violation_to_pydantic(proto) - # Empty string from proto should not set plugin_name - assert violation.plugin_name is None or violation.plugin_name == "" - - def test_zero_mcp_error_code(self): - """Test proto violation to pydantic with zero mcp_error_code.""" - proto = plugin_service_pb2.PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - mcp_error_code=0, - ) - violation = proto_violation_to_pydantic(proto) - assert violation.mcp_error_code is None - - -class TestPydanticResultToProtoBase: - """Tests for pydantic_result_to_proto_base.""" - - def test_basic_conversion(self): - """Test basic result conversion.""" - result = PluginResult(continue_processing=True) - proto = pydantic_result_to_proto_base(result) - assert proto.continue_processing is True - - def test_with_violation(self): - """Test result conversion with violation.""" - violation = PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - ) - result = PluginResult(continue_processing=False, violation=violation) - proto = pydantic_result_to_proto_base(result) - assert proto.continue_processing is False - assert proto.violation.reason == "blocked" - - def test_with_metadata(self): - """Test result conversion with metadata.""" - result = PluginResult(continue_processing=True, metadata={"key": "value"}) - proto = pydantic_result_to_proto_base(result) - meta = json_format.MessageToDict(proto.metadata) - assert meta["key"] == "value" - - def test_no_violation_no_metadata(self): - """Test result conversion without violation or metadata.""" - result = PluginResult(continue_processing=True) - proto = pydantic_result_to_proto_base(result) - assert not proto.HasField("violation") - assert not proto.metadata.fields - - -class TestUpdatePydanticResultFromProtoBase: - """Tests for update_pydantic_result_from_proto_base.""" - - def test_basic_update(self): - """Test basic result update from proto.""" - result = PluginResult(continue_processing=True) - proto = plugin_service_pb2.PluginResultBase(continue_processing=False) - update_pydantic_result_from_proto_base(result, proto) - assert result.continue_processing is False - - def test_update_with_violation(self): - """Test result update with violation.""" - result = PluginResult(continue_processing=True) - proto = plugin_service_pb2.PluginResultBase(continue_processing=False) - proto.violation.CopyFrom( - plugin_service_pb2.PluginViolation( - reason="blocked", - description="Blocked", - code="BLOCKED", - ) - ) - update_pydantic_result_from_proto_base(result, proto) - assert result.violation is not None - assert result.violation.reason == "blocked" - - def test_update_with_metadata(self): - """Test result update with metadata.""" - result = PluginResult(continue_processing=True) - proto = plugin_service_pb2.PluginResultBase(continue_processing=True) - json_format.ParseDict({"key": "updated"}, proto.metadata) - update_pydantic_result_from_proto_base(result, proto) - assert result.metadata["key"] == "updated" - - -class TestUpdatePydanticContextFromProto: - """Tests for update_pydantic_context_from_proto.""" - - def test_update_state(self): - """Test updating context state.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1"), - state={"old": "value"}, - ) - proto = plugin_service_pb2.PluginContext() - json_format.ParseDict({"new": "value"}, proto.state) - - update_pydantic_context_from_proto(ctx, proto) - assert ctx.state["new"] == "value" - assert "old" not in ctx.state - - def test_update_metadata(self): - """Test updating context metadata.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1"), - metadata={"old": "meta"}, - ) - proto = plugin_service_pb2.PluginContext() - json_format.ParseDict({"new": "meta"}, proto.metadata) - - update_pydantic_context_from_proto(ctx, proto) - assert ctx.metadata["new"] == "meta" - - def test_update_global_context_state(self): - """Test updating global context state.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1", state={"old": "gc_state"}), - ) - proto = plugin_service_pb2.PluginContext() - json_format.ParseDict({"new": "gc_state"}, proto.global_context.state) - - update_pydantic_context_from_proto(ctx, proto) - assert ctx.global_context.state["new"] == "gc_state" - - def test_empty_proto_clears_fields(self): - """Test empty proto fields clear the context.""" - ctx = PluginContext( - global_context=GlobalContext(request_id="req-1"), - state={"existing": "value"}, - metadata={"existing": "meta"}, - ) - proto = plugin_service_pb2.PluginContext() - - update_pydantic_context_from_proto(ctx, proto) - assert ctx.state == {} - assert ctx.metadata == {} - - -class TestRoundTrip: - """Tests for round-trip conversions.""" - - def test_global_context_round_trip(self): - """Test GlobalContext round-trip conversion.""" - original = GlobalContext( - request_id="req-1", - server_id="srv-1", - tenant_id="tenant-1", - user="admin", - metadata={"key": "value"}, - state={"counter": 1}, - ) - proto = pydantic_global_context_to_proto(original) - result = proto_global_context_to_pydantic(proto) - - assert result.request_id == original.request_id - assert result.server_id == original.server_id - assert result.tenant_id == original.tenant_id - assert result.user == original.user - assert result.metadata == original.metadata - - def test_plugin_context_round_trip(self): - """Test PluginContext round-trip conversion.""" - original = PluginContext( - global_context=GlobalContext(request_id="req-1", server_id="srv-1"), - state={"key": "value"}, - metadata={"meta": "data"}, - ) - proto = pydantic_context_to_proto(original) - result = proto_context_to_pydantic(proto) - - assert result.global_context.request_id == original.global_context.request_id - assert result.state == original.state - assert result.metadata == original.metadata - - def test_violation_round_trip(self): - """Test PluginViolation round-trip conversion.""" - original = PluginViolation( - reason="blocked", - description="Content blocked by policy", - code="BLOCKED", - plugin_name="TestPlugin", - mcp_error_code=-32600, - details={"severity": "high"}, - ) - proto = pydantic_violation_to_proto(original) - result = proto_violation_to_pydantic(proto) - - assert result.reason == original.reason - assert result.description == original.description - assert result.code == original.code - assert result.plugin_name == original.plugin_name - assert result.mcp_error_code == original.mcp_error_code - assert result.details == original.details diff --git a/tests/unit/cpex/framework/external/unix/README.md b/tests/unit/cpex/framework/external/unix/README.md deleted file mode 100644 index c16f3463..00000000 --- a/tests/unit/cpex/framework/external/unix/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Unix Socket External Plugin Tests - -This directory contains tests for the Unix socket transport layer of the external plugin framework. Unix sockets provide high-performance local IPC using length-prefixed protobuf messages. - -## Test Files - -### `test_client.py` - Unit Tests (Mocked) - -Unit tests for `UnixSocketExternalPlugin` client using mocks. No real server is started. - -| Test | Description | -|------|-------------| -| **TestUnixSocketExternalPluginInit** | | -| `test_init_with_config` | Verifies plugin initializes with name and null reader/writer | -| `test_init_stores_socket_config` | Verifies socket path, timeout, retry settings are stored | -| `test_init_missing_unix_socket_config` | Raises `PluginError` when unix_socket config missing | -| **TestUnixSocketExternalPluginConnected** | | -| `test_connected_false_when_not_connected` | Returns False when `_connected` is False | -| `test_connected_false_when_writer_none` | Returns False when writer is None | -| `test_connected_false_when_writer_closing` | Returns False when writer.is_closing() is True | -| `test_connected_true_when_active` | Returns True when properly connected | -| **TestUnixSocketExternalPluginInitialize** | | -| `test_initialize_connects_to_socket` | Calls `open_unix_connection` with socket path | -| `test_initialize_connection_error` | Raises `PluginError` on connection failure | -| **TestUnixSocketExternalPluginInvokeHook** | | -| `test_invoke_hook_success` | Successfully invokes hook and returns result | -| `test_invoke_hook_not_connected` | Attempts reconnection when disconnected | -| `test_invoke_hook_error_response` | Handles error responses from server | -| `test_invoke_hook_timeout` | Raises `PluginError` on read timeout | -| `test_invoke_hook_unregistered_hook_type` | Raises error for unknown hook types | -| **TestUnixSocketExternalPluginShutdown** | | -| `test_shutdown_closes_connection` | Writer is closed and references cleared | -| `test_shutdown_no_connection` | Safe to call when not connected | -| `test_shutdown_idempotent` | Multiple shutdown calls don't raise errors | -| **TestUnixSocketExternalPluginReconnect** | | -| `test_reconnect_success_after_failure` | Retries and succeeds after initial failure | -| `test_reconnect_all_attempts_fail` | Raises after max retry attempts | - -### `test_client_integration.py` - Integration Tests (Real Server) - -Integration tests that spawn a real Unix socket server subprocess and test actual communication. - -**Direct Plugin Tests:** - -| Test | Description | -|------|-------------| -| `test_unix_client_invoke_hook` | Invokes `prompt_pre_fetch` hook, verifies word replacement ("crap" → "yikes") | -| `test_unix_client_post_hook` | Invokes `prompt_post_fetch` hook, verifies message text transformation | -| `test_unix_client_multiple_calls` | Makes 5 sequential calls to verify connection reuse | -| `test_unix_client_context_propagation` | Verifies request_id, server_id, user, tenant_id are passed through | -| `test_unix_client_high_throughput` | Makes 50 rapid calls, asserts >50 calls/sec throughput | - -**PluginManager Tests:** - -| Test | Description | -|------|-------------| -| `test_unix_plugin_manager_invoke_hook` | Tests PluginManager loading and invoking hooks through Unix socket external plugin | -| `test_unix_plugin_manager_multiple_hooks` | Tests PluginManager invoking both pre-fetch and post-fetch hooks | -| `test_unix_plugin_manager_context_persistence` | Tests context persistence across multiple PluginManager calls | - -All integration tests are skipped on Windows (no Unix socket support). - -### `test_server.py` - Server Unit Tests (Mocked) - -Unit tests for `UnixSocketPluginServer` message handling. - -| Test | Description | -|------|-------------| -| **TestUnixSocketPluginServerProperties** | | -| `test_socket_path` | Returns correct socket path | -| `test_running_initially_false` | Server not running before start() | -| **TestUnixSocketPluginServerHandleMessage** | | -| `test_handle_invoke_hook_request` | Parses and handles InvokeHookRequest | -| `test_handle_get_plugin_config_request_found` | Returns config when plugin exists | -| `test_handle_get_plugin_config_request_not_found` | Returns `found=False` when not exists | -| `test_handle_get_plugin_configs_request` | Returns all plugin configs | -| **TestUnixSocketPluginServerInvokeHook** | | -| `test_invoke_hook_success` | Returns successful result | -| `test_invoke_hook_with_error` | Returns error details in response | -| `test_invoke_hook_with_context_update` | Includes updated context in response | -| `test_invoke_hook_unexpected_error` | Handles unexpected exceptions | -| **TestUnixSocketPluginServerLifecycle** | | -| `test_start_creates_socket` | Socket file is created on start | -| `test_stop_cleans_up` | Socket file is removed on stop | -| `test_serve_forever_requires_start` | Raises RuntimeError if not started | - -### `test_protocol.py` - Protocol Tests - -Tests for the length-prefixed message framing protocol. - -| Test | Description | -|------|-------------| -| **TestWriteMessage** | | -| `test_write_message_basic` | Writes 4-byte length prefix + payload | -| `test_write_message_empty` | Handles zero-length messages | -| `test_write_message_large` | Handles 100KB messages | -| **TestWriteMessageAsync** | | -| `test_write_message_async_basic` | Writes and drains asynchronously | -| **TestReadMessage** | | -| `test_read_message_basic` | Reads length prefix then payload | -| `test_read_message_with_timeout` | Honors timeout parameter | -| `test_read_message_timeout_error` | Raises TimeoutError on timeout | -| `test_read_message_incomplete_read` | Handles connection closed mid-read | -| `test_read_message_zero_length` | Handles zero-length messages | -| `test_read_message_large` | Handles 100KB messages | -| **TestProtocolError** | | -| `test_protocol_error_message` | Error has message attribute | -| `test_protocol_error_inheritance` | Inherits from Exception | -| **TestRoundTrip** | | -| `test_round_trip_basic` | Encode then decode returns original | -| `test_round_trip_protobuf` | Works with actual protobuf messages | - -## Wire Protocol - -The Unix socket transport uses length-prefixed protobuf messages: - -``` -[4-byte big-endian length][protobuf payload] -``` - -Messages use the same `plugin_service.proto` schema as gRPC (`InvokeHookRequest`, `InvokeHookResponse`, etc.). - -## Running Tests - -```bash -# Run all Unix socket tests -pytest tests/unit/cpex/framework/external/unix/ -v - -# Run only unit tests (fast, no subprocess) -pytest tests/unit/cpex/framework/external/unix/test_client.py tests/unit/cpex/framework/external/unix/test_server.py tests/unit/cpex/framework/external/unix/test_protocol.py -v - -# Run only integration tests (spawns real server) -pytest tests/unit/cpex/framework/external/unix/test_client_integration.py -v -``` - -## Test Fixtures - -- `unix_server_proc`: Starts Unix socket server in `/tmp` with unique path -- `mock_plugin_config`: Creates test PluginConfig with socket path -- `server`: Creates UnixSocketPluginServer with mock plugin server - -## Platform Notes - -- All integration tests are **skipped on Windows** (no Unix domain socket support) -- Socket paths use `/tmp` directly to avoid macOS path length limits (~104 chars) diff --git a/tests/unit/cpex/framework/external/unix/__init__.py b/tests/unit/cpex/framework/external/unix/__init__.py deleted file mode 100644 index b06eeb40..00000000 --- a/tests/unit/cpex/framework/external/unix/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""Unix socket transport tests.""" diff --git a/tests/unit/cpex/framework/external/unix/test_client.py b/tests/unit/cpex/framework/external/unix/test_client.py deleted file mode 100644 index 474c0e2e..00000000 --- a/tests/unit/cpex/framework/external/unix/test_client.py +++ /dev/null @@ -1,661 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/unix/test_client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for Unix socket external plugin client. -Tests for UnixSocketExternalPlugin initialization, hook invocation, and error handling. -""" - -# Standard -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import pytest -from pydantic import ValidationError - -# First-Party -from cpex.framework import ToolPreInvokePayload -from cpex.framework.errors import PluginError -from cpex.framework.models import ( - GlobalContext, - PluginConfig, - PluginContext, - PluginErrorModel, - UnixSocketClientConfig, -) - -# Check if grpc/protobuf is available -try: - from google.protobuf import json_format - from google.protobuf.struct_pb2 import Struct - - from cpex.framework.external.unix.client import UnixSocketExternalPlugin - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - json_format = None # type: ignore - Struct = None # type: ignore - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed (required for protobuf)") - - -@pytest.fixture -def mock_plugin_config(tmp_path): - """Create a mock plugin config for testing.""" - socket_path = str(tmp_path / "test.sock") - return PluginConfig( - name="TestUnixPlugin", - kind="external", - hooks=["tool_pre_invoke"], - unix_socket=UnixSocketClientConfig( - path=socket_path, - timeout=5.0, - reconnect_attempts=2, - reconnect_delay=0.1, - ), - ) - - -class TestUnixSocketExternalPluginInit: - """Tests for UnixSocketExternalPlugin initialization.""" - - def test_init_with_config(self, mock_plugin_config): - """Test plugin initialization with valid config.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - assert plugin.name == "TestUnixPlugin" - assert plugin._reader is None - assert plugin._writer is None - assert plugin._connected is False - - def test_init_stores_socket_config(self, mock_plugin_config): - """Test plugin stores socket configuration.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - assert plugin._socket_path == mock_plugin_config.unix_socket.path - assert plugin._timeout == 5.0 - assert plugin._reconnect_attempts == 2 - assert plugin._reconnect_delay == 0.1 - - def test_init_missing_unix_socket_config(self): - """Test PluginConfig validation rejects external plugin without transport config.""" - with pytest.raises(ValidationError, match="External plugin.*must have"): - PluginConfig( - name="TestPlugin", - kind="external", - hooks=["tool_pre_invoke"], - ) - - -class TestUnixSocketExternalPluginConnected: - """Tests for UnixSocketExternalPlugin.connected property.""" - - def test_connected_false_when_not_connected(self, mock_plugin_config): - """Test connected returns False when not connected.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - assert plugin.connected is False - - def test_connected_false_when_writer_none(self, mock_plugin_config): - """Test connected returns False when writer is None.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = True - plugin._writer = None - assert plugin.connected is False - - def test_connected_false_when_writer_closing(self, mock_plugin_config): - """Test connected returns False when writer is closing.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = True - mock_writer = MagicMock() - mock_writer.is_closing.return_value = True - plugin._writer = mock_writer - assert plugin.connected is False - - def test_connected_true_when_active(self, mock_plugin_config): - """Test connected returns True when properly connected.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = True - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - plugin._writer = mock_writer - assert plugin.connected is True - - -class TestUnixSocketExternalPluginInitialize: - """Tests for UnixSocketExternalPlugin.initialize().""" - - @pytest.mark.asyncio - async def test_initialize_connects_to_socket(self, mock_plugin_config): - """Test initialize establishes socket connection.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - - # Mock the config response - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - config_response = plugin_service_pb2.GetPluginConfigResponse() - config_response.found = True - - with patch("asyncio.open_unix_connection", return_value=(mock_reader, mock_writer)) as mock_connect: - with patch.object(plugin, "_writer", mock_writer): - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=config_response.SerializeToString(), - ): - await plugin.initialize() - - mock_connect.assert_called_once_with(mock_plugin_config.unix_socket.path) - - @pytest.mark.asyncio - async def test_initialize_connection_error(self, mock_plugin_config): - """Test initialize handles connection errors.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - with patch("asyncio.open_unix_connection", side_effect=OSError("Connection refused")): - with pytest.raises(PluginError, match="Failed to connect"): - await plugin.initialize() - - -class TestUnixSocketExternalPluginInvokeHook: - """Tests for UnixSocketExternalPlugin.invoke_hook().""" - - @pytest.fixture - def initialized_plugin(self, mock_plugin_config): - """Create an initialized plugin for testing.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = True - plugin._reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - plugin._writer = mock_writer - return plugin - - @pytest.mark.asyncio - async def test_invoke_hook_success(self, initialized_plugin): - """Test successful hook invocation.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - # Create mock response - response = plugin_service_pb2.InvokeHookResponse() - result_struct = Struct() - json_format.ParseDict({"continue_processing": True}, result_struct) - response.result.CopyFrom(result_struct) - - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=response.SerializeToString(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={"arg1": "value1"}) - - result = await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - assert result is not None - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_invoke_hook_not_connected(self, mock_plugin_config): - """Test invoke_hook reconnects when not connected.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = False - - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError): - await plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_error_response(self, initialized_plugin): - """Test invoke_hook handles error response from server.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - # Create error response - response = plugin_service_pb2.InvokeHookResponse() - response.error.message = "Plugin processing failed" - response.error.plugin_name = "TestUnixPlugin" - response.error.code = "PROCESSING_ERROR" - - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=response.SerializeToString(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="Plugin processing failed"): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_timeout(self, initialized_plugin): - """Test invoke_hook handles timeout.""" - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - side_effect=asyncio.TimeoutError(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="timed out"): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_unregistered_hook_type(self, initialized_plugin): - """Test invoke_hook raises error for unregistered hook type.""" - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="not registered"): - await initialized_plugin.invoke_hook("invalid_hook_type", payload, context) - - -class TestUnixSocketExternalPluginShutdown: - """Tests for UnixSocketExternalPlugin.shutdown().""" - - @pytest.mark.asyncio - async def test_shutdown_closes_connection(self, mock_plugin_config): - """Test shutdown closes the socket connection.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - mock_writer = MagicMock() - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - plugin._writer = mock_writer - plugin._reader = AsyncMock() - plugin._connected = True - - await plugin.shutdown() - - mock_writer.close.assert_called_once() - assert plugin._writer is None - assert plugin._reader is None - assert plugin._connected is False - - @pytest.mark.asyncio - async def test_shutdown_no_connection(self, mock_plugin_config): - """Test shutdown handles case when not connected.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - # Should not raise - await plugin.shutdown() - - @pytest.mark.asyncio - async def test_shutdown_idempotent(self, mock_plugin_config): - """Test shutdown can be called multiple times safely.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - mock_writer = MagicMock() - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - plugin._writer = mock_writer - plugin._reader = AsyncMock() - plugin._connected = True - - await plugin.shutdown() - await plugin.shutdown() # Second call should not raise - - # close should only be called once - mock_writer.close.assert_called_once() - - -class TestUnixSocketExternalPluginReconnect: - """Tests for reconnection logic in UnixSocketExternalPlugin.""" - - @pytest.mark.asyncio - async def test_reconnect_success_after_failure(self, mock_plugin_config): - """Test reconnection succeeds after initial failure.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - - # First attempt fails, second succeeds - with patch( - "asyncio.open_unix_connection", - side_effect=[OSError("Connection refused"), (mock_reader, mock_writer)], - ): - with patch("asyncio.sleep", new_callable=AsyncMock): - await plugin._reconnect() - - assert plugin._connected is True - - @pytest.mark.asyncio - async def test_reconnect_all_attempts_fail(self, mock_plugin_config): - """Test reconnection raises after all attempts fail.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - with patch("asyncio.open_unix_connection", side_effect=OSError("Connection refused")): - with patch("asyncio.sleep", new_callable=AsyncMock): - with pytest.raises(PluginError, match="Failed to reconnect"): - await plugin._reconnect() - - -class TestUnixSocketExternalPluginSendRequest: - """Tests for _send_request retry and error handling.""" - - @pytest.fixture - def connected_plugin(self, mock_plugin_config): - """Create a connected plugin for testing.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = True - plugin._reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - plugin._writer = mock_writer - return plugin - - @pytest.mark.asyncio - async def test_send_request_connection_error_retry(self, mock_plugin_config): - """Test _send_request retries on connection error.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = False - - # Build a minimal request - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - with patch.object( - plugin, - "_reconnect", - new_callable=AsyncMock, - side_effect=PluginError(error=PluginErrorModel(message="Failed", plugin_name="test")), - ): - with patch("asyncio.sleep", new_callable=AsyncMock): - with pytest.raises(PluginError): - await plugin._send_request(request) - - @pytest.mark.asyncio - async def test_send_request_os_error_retries(self, connected_plugin): - """Test _send_request retries on OSError during write.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - call_count = 0 - - async def failing_write(*args, **kwargs): - nonlocal call_count - call_count += 1 - raise OSError("Connection reset") - - # Mock reconnect to succeed (so we actually retry the write) - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - - with patch( - "cpex.framework.external.unix.client.write_message_async", - side_effect=failing_write, - ): - with patch("asyncio.sleep", new_callable=AsyncMock): - with patch( - "asyncio.open_unix_connection", - return_value=(mock_reader, mock_writer), - ): - with pytest.raises(PluginError): - await connected_plugin._send_request(request) - - # Should have attempted multiple times (initial + reconnect attempts) - assert call_count >= 2 - - -class TestUnixSocketExternalPluginInitializeEdgeCases: - """Tests for initialize edge cases.""" - - @pytest.mark.asyncio - async def test_initialize_unexpected_exception(self, mock_plugin_config): - """Test initialize handles non-PluginError exceptions.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - with patch("asyncio.open_unix_connection", side_effect=ValueError("Unexpected")): - with pytest.raises(PluginError): - await plugin.initialize() - - @pytest.mark.asyncio - async def test_initialize_config_not_found(self, mock_plugin_config): - """Test initialize continues when remote plugin config not found.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - - # Config not found response - config_response = plugin_service_pb2.GetPluginConfigResponse() - config_response.found = False - - with patch("asyncio.open_unix_connection", return_value=(mock_reader, mock_writer)): - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=config_response.SerializeToString(), - ): - # Should not raise even if config not found - await plugin.initialize() - assert plugin._connected is True - - @pytest.mark.asyncio - async def test_initialize_config_verification_fails(self, mock_plugin_config): - """Test initialize continues when config verification fails.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - - with patch("asyncio.open_unix_connection", return_value=(mock_reader, mock_writer)): - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - side_effect=[None, Exception("Write failed")], - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - side_effect=Exception("Read failed"), - ): - # Should still initialize (config verification is best-effort) - await plugin.initialize() - assert plugin._connected is True - - -class TestUnixSocketExternalPluginInvokeHookEdgeCases: - """Tests for invoke_hook edge cases.""" - - @pytest.fixture - def initialized_plugin(self, mock_plugin_config): - """Create an initialized plugin for testing.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - plugin._connected = True - plugin._reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.is_closing.return_value = False - plugin._writer = mock_writer - return plugin - - @pytest.mark.asyncio - async def test_invoke_hook_with_dict_payload(self, initialized_plugin): - """Test invoke_hook with dict payload (not pydantic model).""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - # Create success response - response = plugin_service_pb2.InvokeHookResponse() - result_struct = Struct() - json_format.ParseDict({"continue_processing": True}, result_struct) - response.result.CopyFrom(result_struct) - - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=response.SerializeToString(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - # Pass dict payload instead of pydantic model - payload = {"name": "test_tool", "args": {}} - - result = await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_invoke_hook_with_context_update(self, initialized_plugin): - """Test invoke_hook updates context from response.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - # Create response with context update - response = plugin_service_pb2.InvokeHookResponse() - result_struct = Struct() - json_format.ParseDict({"continue_processing": True}, result_struct) - response.result.CopyFrom(result_struct) - - # Add context with state - from google.protobuf import json_format as jf - - jf.ParseDict({"updated_key": "updated_value"}, response.context.state) - response.context.global_context.request_id = "req-1" - - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=response.SerializeToString(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - result = await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - assert result.continue_processing is True - # Context should be updated - assert ( - context.state.get("updatedKey") == "updated_value" - or context.state.get("updated_key") == "updated_value" - ) - - @pytest.mark.asyncio - async def test_invoke_hook_error_with_details(self, initialized_plugin): - """Test invoke_hook handles error response with details.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - response = plugin_service_pb2.InvokeHookResponse() - response.error.message = "Error with details" - response.error.plugin_name = "TestPlugin" - response.error.code = "ERR" - from google.protobuf import json_format as jf - - jf.ParseDict({"extra": "info"}, response.error.details) - - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=response.SerializeToString(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError) as exc_info: - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - assert "Error with details" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_invoke_hook_invalid_response(self, initialized_plugin): - """Test invoke_hook handles response without result or error.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - # Empty response (no result, no error) - response = plugin_service_pb2.InvokeHookResponse() - - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - ): - with patch( - "cpex.framework.external.unix.client.read_message", - new_callable=AsyncMock, - return_value=response.SerializeToString(), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError, match="invalid response"): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - async def test_invoke_hook_generic_exception(self, initialized_plugin): - """Test invoke_hook wraps generic exceptions in PluginError.""" - with patch( - "cpex.framework.external.unix.client.write_message_async", - new_callable=AsyncMock, - side_effect=ValueError("Unexpected serialization error"), - ): - context = PluginContext(global_context=GlobalContext(request_id="test", server_id="test")) - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError): - await initialized_plugin.invoke_hook("tool_pre_invoke", payload, context) - - -class TestUnixSocketExternalPluginDisconnect: - """Tests for _disconnect edge cases.""" - - @pytest.mark.asyncio - async def test_disconnect_writer_exception(self, mock_plugin_config): - """Test _disconnect handles writer close exception.""" - plugin = UnixSocketExternalPlugin(mock_plugin_config) - mock_writer = MagicMock() - mock_writer.close = MagicMock(side_effect=OSError("Close failed")) - mock_writer.wait_closed = AsyncMock(side_effect=OSError("Wait failed")) - plugin._writer = mock_writer - plugin._reader = AsyncMock() - plugin._connected = True - - # Should not raise - await plugin._disconnect() - assert plugin._writer is None - assert plugin._connected is False diff --git a/tests/unit/cpex/framework/external/unix/test_client_integration.py b/tests/unit/cpex/framework/external/unix/test_client_integration.py deleted file mode 100644 index cf9ec0d7..00000000 --- a/tests/unit/cpex/framework/external/unix/test_client_integration.py +++ /dev/null @@ -1,401 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/unix/test_client_integration.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Integration tests for Unix socket external plugin client. -These tests spawn a real Unix socket server subprocess and test actual communication. -""" - -# Standard -import os -import stat -import subprocess -import sys -import time -import uuid - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - ConfigLoader, - GlobalContext, - PluginContext, - PluginLoader, - PluginManager, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, -) -from tests.unit.cpex.fixtures.common.models import ( - Message, - PromptResult, - Role, - TextContent, -) - -# Check if grpc/protobuf is available (Unix socket uses protobuf from grpc package) -try: - import grpc # noqa: F401 - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed (required for protobuf)") - - -def _wait_for_socket(path: str, timeout: float = 15.0, proc: subprocess.Popen | None = None) -> None: - """Wait until a Unix domain socket path exists and is ready.""" - start = time.time() - while time.time() - start < timeout: - if proc and proc.poll() is not None: - output = "" - if proc.stdout: - output = proc.stdout.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Server exited before socket created. Output:\n{output}") - try: - if os.path.exists(path) and stat.S_ISSOCK(os.stat(path).st_mode): - # Give it a moment to be fully ready - time.sleep(0.1) - return - except FileNotFoundError: - pass - time.sleep(0.1) - raise RuntimeError(f"Timed out waiting for socket: {path}") - - -@pytest.fixture -def unix_server_proc(): - """Start a Unix socket plugin server subprocess.""" - # Use /tmp directly to keep socket path short (macOS has ~104 char limit) - short_id = uuid.uuid4().hex[:8] - socket_path = f"/tmp/unix-test-{short_id}.sock" - - current_env = os.environ.copy() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "unix" - current_env["PLUGINS_UNIX_SOCKET_PATH"] = socket_path - - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/unix/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_socket(socket_path, proc=server_proc) - yield server_proc, socket_path - server_proc.terminate() - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait(timeout=3) - finally: - if os.path.exists(socket_path): - os.unlink(socket_path) - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_client_invoke_hook(unix_server_proc): - """Test Unix socket client can invoke hooks on a real server.""" - server_proc, socket_path = unix_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml") - config.plugins[0].unix_socket.path = socket_path - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - # Test prompt_pre_fetch hook - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - - # The ReplaceBadWordsPlugin replaces "crap" -> "crud" -> "yikes" - assert result.modified_payload.args["user"] == "What a yikesshow!" - - # Verify plugin is connected - assert plugin.connected is True - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_client_post_hook(unix_server_proc): - """Test Unix socket client can invoke post-fetch hooks.""" - server_proc, socket_path = unix_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml") - config.plugins[0].unix_socket.path = socket_path - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - # Test prompt_post_fetch hook - message = Message(content=TextContent(type="text", text="What the crud?"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result = await plugin.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload_result, context) - - assert len(result.modified_payload.result.messages) == 1 - # "crud" -> "yikes" - assert result.modified_payload.result.messages[0].content.text == "What the yikes?" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_client_multiple_calls(unix_server_proc): - """Test Unix socket client handles multiple sequential calls.""" - server_proc, socket_path = unix_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml") - config.plugins[0].unix_socket.path = socket_path - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - # Make multiple calls to verify connection reuse - for i in range(5): - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": f"Test crap {i}"}) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - assert result.modified_payload.args["user"] == f"Test yikes {i}" - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_client_context_propagation(unix_server_proc): - """Test that context is properly propagated through Unix socket calls.""" - server_proc, socket_path = unix_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml") - config.plugins[0].unix_socket.path = socket_path - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - # Create context with initial state - global_context = GlobalContext( - request_id="test-req-123", - server_id="test-server", - user="test-user", - tenant_id="test-tenant", - ) - context = PluginContext(global_context=global_context) - - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "Hello!"}) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - - # Verify the call succeeded - assert result.continue_processing is True - finally: - await plugin.shutdown() - await loader.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_client_high_throughput(unix_server_proc): - """Test Unix socket client handles high throughput.""" - server_proc, socket_path = unix_server_proc - assert not server_proc.poll(), "Server failed to start" - - config = ConfigLoader.load_config("tests/unit/cpex/fixtures/configs/valid_unix_external_plugin.yaml") - config.plugins[0].unix_socket.path = socket_path - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - try: - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - - # Make many rapid calls - import time - - start = time.perf_counter() - num_calls = 50 - for i in range(num_calls): - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "crap"}) - result = await plugin.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, context) - assert result.modified_payload.args["user"] == "yikes" - - elapsed = time.perf_counter() - start - rate = num_calls / elapsed - - # Unix sockets should be fast - use lenient threshold for CI/slow environments - assert rate > 10, f"Rate too slow: {rate:.0f} calls/sec" - finally: - await plugin.shutdown() - await loader.shutdown() - - -# ============================================================================= -# PluginManager Integration Tests -# ============================================================================= - -# Fixed socket path for PluginManager tests (matches valid_unix_external_plugin_manager.yaml) -PLUGIN_MANAGER_SOCKET_PATH = "/tmp/cpex-pm-test.sock" - - -@pytest.fixture -def unix_server_proc_for_manager(): - """Start a Unix socket plugin server on the fixed path for PluginManager tests.""" - current_env = os.environ.copy() - current_env["PLUGINS_CONFIG_PATH"] = "tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - current_env["PYTHONPATH"] = "." - current_env["PLUGINS_TRANSPORT"] = "unix" - current_env["PLUGINS_UNIX_SOCKET_PATH"] = PLUGIN_MANAGER_SOCKET_PATH - - # Clean up any existing socket - if os.path.exists(PLUGIN_MANAGER_SOCKET_PATH): - os.unlink(PLUGIN_MANAGER_SOCKET_PATH) - - try: - with subprocess.Popen( - [sys.executable, "cpex/framework/external/unix/server/runtime.py"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=current_env, - ) as server_proc: - _wait_for_socket(PLUGIN_MANAGER_SOCKET_PATH, proc=server_proc) - yield server_proc - server_proc.terminate() - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait(timeout=3) - finally: - if os.path.exists(PLUGIN_MANAGER_SOCKET_PATH): - os.unlink(PLUGIN_MANAGER_SOCKET_PATH) - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_plugin_manager_invoke_hook(unix_server_proc_for_manager): - """Test PluginManager can invoke hooks through Unix socket external plugin.""" - server_proc = unix_server_proc_for_manager - assert not server_proc.poll(), "Server failed to start" - - # Reset PluginManager singleton state - PluginManager.reset() - - plugin_manager = PluginManager(config="tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml") - - try: - await plugin_manager.initialize() - - # Verify plugin was loaded - assert plugin_manager.plugin_count == 1 - - # Test prompt_pre_fetch hook through PluginManager - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - global_context = GlobalContext(request_id="test-1", server_id="test-server") - - result, contexts = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH.value, - payload, - global_context, - ) - - # Verify the transformation happened - assert result.modified_payload.args["user"] == "What a yikesshow!" - assert result.continue_processing is True - - finally: - await plugin_manager.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_plugin_manager_multiple_hooks(unix_server_proc_for_manager): - """Test PluginManager can invoke multiple hook types through Unix socket.""" - server_proc = unix_server_proc_for_manager - assert not server_proc.poll(), "Server failed to start" - - PluginManager.reset() - plugin_manager = PluginManager(config="tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml") - - try: - await plugin_manager.initialize() - - global_context = GlobalContext(request_id="test-1", server_id="test-server") - - # Test prompt_pre_fetch - pre_payload = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "This is crap!"}) - result, _ = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH.value, - pre_payload, - global_context, - ) - assert result.modified_payload.args["user"] == "This is yikes!" - - # Test prompt_post_fetch - message = Message(content=TextContent(type="text", text="What crud!"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - post_payload = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result, _ = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_POST_FETCH.value, - post_payload, - global_context, - ) - assert result.modified_payload.result.messages[0].content.text == "What yikes!" - - finally: - await plugin_manager.shutdown() - - -@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix domain sockets are not supported on Windows.") -@pytest.mark.asyncio -async def test_unix_plugin_manager_context_persistence(unix_server_proc_for_manager): - """Test that context is maintained across multiple PluginManager calls.""" - server_proc = unix_server_proc_for_manager - assert not server_proc.poll(), "Server failed to start" - - PluginManager.reset() - plugin_manager = PluginManager(config="tests/unit/cpex/fixtures/configs/valid_unix_external_plugin_manager.yaml") - - try: - await plugin_manager.initialize() - - global_context = GlobalContext( - request_id="ctx-test-123", - server_id="test-server", - user="test-user", - tenant_id="test-tenant", - ) - - # Make multiple calls and verify context flows through - for i in range(3): - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"user": f"Test crap {i}"}) - result, contexts = await plugin_manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH.value, - payload, - global_context, - ) - assert result.modified_payload.args["user"] == f"Test yikes {i}" - assert result.continue_processing is True - - finally: - await plugin_manager.shutdown() diff --git a/tests/unit/cpex/framework/external/unix/test_protocol.py b/tests/unit/cpex/framework/external/unix/test_protocol.py deleted file mode 100644 index b31d9824..00000000 --- a/tests/unit/cpex/framework/external/unix/test_protocol.py +++ /dev/null @@ -1,299 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/unix/test_protocol.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for Unix socket protocol utilities. -Tests for length-prefixed message encoding/decoding. -""" - -# Standard -import asyncio -import struct -from unittest.mock import AsyncMock, MagicMock - -# Third-Party -import pytest - -# Check if grpc/protobuf is available -try: - # First-Party - from cpex.framework.external.unix.protocol import ( - ProtocolError, - read_message, - write_message, - write_message_async, - ) - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - json_format = None # type: ignore - Struct = None # type: ignore - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed (required for protobuf)") - - -class TestWriteMessage: - """Tests for write_message function.""" - - def test_write_message_basic(self): - """Test writing a basic message.""" - data = b"Hello, World!" - mock_writer = MagicMock() - written_data = [] - mock_writer.write = MagicMock(side_effect=lambda x: written_data.append(x)) - - write_message(mock_writer, data) - - # Check that write was called - mock_writer.write.assert_called_once() - result = written_data[0] - - # Check length prefix (4 bytes, big-endian) - length = struct.unpack(">I", result[:4])[0] - assert length == len(data) - - # Check payload - assert result[4:] == data - - def test_write_message_empty(self): - """Test writing an empty message.""" - data = b"" - mock_writer = MagicMock() - written_data = [] - mock_writer.write = MagicMock(side_effect=lambda x: written_data.append(x)) - - write_message(mock_writer, data) - - result = written_data[0] - length = struct.unpack(">I", result[:4])[0] - assert length == 0 - assert result == b"\x00\x00\x00\x00" - - def test_write_message_large(self): - """Test writing a large message.""" - data = b"x" * 100000 - mock_writer = MagicMock() - written_data = [] - mock_writer.write = MagicMock(side_effect=lambda x: written_data.append(x)) - - write_message(mock_writer, data) - - result = written_data[0] - length = struct.unpack(">I", result[:4])[0] - assert length == 100000 - assert result[4:] == data - - -class TestWriteMessageAsync: - """Tests for write_message_async function.""" - - @pytest.mark.asyncio - async def test_write_message_async_basic(self): - """Test async writing a basic message.""" - mock_writer = MagicMock() - mock_writer.write = MagicMock() - mock_writer.drain = AsyncMock() - - data = b"Hello, World!" - await write_message_async(mock_writer, data) - - # Verify write was called with length-prefixed message - mock_writer.write.assert_called_once() - written = mock_writer.write.call_args[0][0] - length = struct.unpack(">I", written[:4])[0] - assert length == len(data) - assert written[4:] == data - - # Verify drain was called - mock_writer.drain.assert_called_once() - - -class TestReadMessage: - """Tests for read_message function.""" - - @pytest.mark.asyncio - async def test_read_message_basic(self): - """Test reading a basic message.""" - data = b"Hello, World!" - length_prefix = struct.pack(">I", len(data)) - - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=[length_prefix, data]) - - result = await read_message(mock_reader) - - assert result == data - - @pytest.mark.asyncio - async def test_read_message_with_timeout(self): - """Test reading with timeout.""" - data = b"Hello!" - length_prefix = struct.pack(">I", len(data)) - - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=[length_prefix, data]) - - result = await read_message(mock_reader, timeout=5.0) - - assert result == data - - @pytest.mark.asyncio - async def test_read_message_timeout_error(self): - """Test read timeout raises TimeoutError.""" - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=asyncio.TimeoutError()) - - with pytest.raises(asyncio.TimeoutError): - await read_message(mock_reader, timeout=0.1) - - @pytest.mark.asyncio - async def test_read_message_incomplete_read(self): - """Test handling incomplete read.""" - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=asyncio.IncompleteReadError(b"", 4)) - - with pytest.raises(asyncio.IncompleteReadError): - await read_message(mock_reader) - - @pytest.mark.asyncio - async def test_read_message_zero_length(self): - """Test reading a zero-length message.""" - length_prefix = struct.pack(">I", 0) - - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=[length_prefix, b""]) - - result = await read_message(mock_reader) - - assert result == b"" - - @pytest.mark.asyncio - async def test_read_message_large(self): - """Test reading a large message.""" - data = b"x" * 100000 - length_prefix = struct.pack(">I", len(data)) - - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=[length_prefix, data]) - - result = await read_message(mock_reader) - - assert result == data - assert len(result) == 100000 - - -class TestProtocolError: - """Tests for ProtocolError exception.""" - - def test_protocol_error_message(self): - """Test ProtocolError has message.""" - error = ProtocolError("Invalid message format") - assert str(error) == "Invalid message format" - - def test_protocol_error_inheritance(self): - """Test ProtocolError inherits from Exception.""" - error = ProtocolError("Test error") - assert isinstance(error, Exception) - - -class TestRoundTrip: - """Tests for round-trip encoding/decoding.""" - - @pytest.mark.asyncio - async def test_round_trip_basic(self): - """Test encoding then decoding a message.""" - original_data = b"Test message for round trip" - - # Encode using mock writer to capture the output - mock_writer = MagicMock() - written_data = [] - mock_writer.write = MagicMock(side_effect=lambda x: written_data.append(x)) - write_message(mock_writer, original_data) - encoded = written_data[0] - - # Create a mock reader that returns the encoded data - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=[encoded[:4], encoded[4:]]) - - # Decode - decoded = await read_message(mock_reader) - - assert decoded == original_data - - @pytest.mark.asyncio - async def test_round_trip_protobuf(self): - """Test round-trip with actual protobuf message.""" - from cpex.framework.external.grpc.proto import plugin_service_pb2 - - # Create a protobuf message - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - original_data = request.SerializeToString() - - # Encode using mock writer to capture the output - mock_writer = MagicMock() - written_data = [] - mock_writer.write = MagicMock(side_effect=lambda x: written_data.append(x)) - write_message(mock_writer, original_data) - encoded = written_data[0] - - # Create a mock reader - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(side_effect=[encoded[:4], encoded[4:]]) - - # Decode - decoded = await read_message(mock_reader) - - # Parse back to protobuf - parsed_request = plugin_service_pb2.InvokeHookRequest() - parsed_request.ParseFromString(decoded) - - assert parsed_request.hook_type == "tool_pre_invoke" - assert parsed_request.plugin_name == "TestPlugin" - - -class TestProtocolLimits: - """Tests for protocol size limits.""" - - def test_write_message_exceeds_max_size(self): - """Test write_message raises ProtocolError for oversized messages.""" - from cpex.framework.external.unix.protocol import MAX_MESSAGE_SIZE - - data = b"x" * (MAX_MESSAGE_SIZE + 1) - mock_writer = MagicMock() - - with pytest.raises(ProtocolError, match="exceeds maximum"): - write_message(mock_writer, data) - - @pytest.mark.asyncio - async def test_read_message_exceeds_max_size(self): - """Test read_message raises ProtocolError for oversized messages.""" - from cpex.framework.external.unix.protocol import MAX_MESSAGE_SIZE - - # Encode a length prefix larger than MAX_MESSAGE_SIZE - oversized_length = MAX_MESSAGE_SIZE + 1 - length_prefix = struct.pack(">I", oversized_length) - - mock_reader = AsyncMock() - mock_reader.readexactly = AsyncMock(return_value=length_prefix) - - with pytest.raises(ProtocolError, match="exceeds maximum"): - await read_message(mock_reader) - - @pytest.mark.asyncio - async def test_write_message_async_no_drain(self): - """Test write_message_async with drain=False.""" - mock_writer = MagicMock() - mock_writer.write = MagicMock() - mock_writer.drain = AsyncMock() - - data = b"Hello!" - await write_message_async(mock_writer, data, drain=False) - - mock_writer.write.assert_called_once() - mock_writer.drain.assert_not_called() diff --git a/tests/unit/cpex/framework/external/unix/test_runtime.py b/tests/unit/cpex/framework/external/unix/test_runtime.py deleted file mode 100644 index 2f439ec8..00000000 --- a/tests/unit/cpex/framework/external/unix/test_runtime.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/unix/test_runtime.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for Unix socket plugin server runtime. -Tests for run() and main() entry points. -""" - -# Standard -import asyncio -import os -from unittest.mock import AsyncMock, patch - -# Third-Party -import pytest - -try: - from cpex.framework.external.unix.server.runtime import main, run - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed") - - -class TestUnixRuntimeRun: - """Tests for the run() async entry point.""" - - @pytest.mark.asyncio - async def test_run_uses_default_config(self): - """Test run() reads config from environment with defaults.""" - mock_run_server = AsyncMock() - - with patch.dict(os.environ, {}, clear=True): - with patch( - "cpex.framework.external.unix.server.runtime.run_server", - mock_run_server, - ): - await run() - - mock_run_server.assert_called_once() - # run_server is called with keyword args: config_path and socket_path - call_args = mock_run_server.call_args - # Check that config_path contains "config.yaml" - config_path = call_args.kwargs.get("config_path") or call_args[1].get("config_path") - assert config_path is not None - assert "config.yaml" in config_path - - @pytest.mark.asyncio - async def test_run_uses_env_vars(self): - """Test run() reads config from environment variables.""" - mock_run_server = AsyncMock() - - env_vars = { - "PLUGINS_CONFIG_PATH": "/custom/config.yaml", - "PLUGINS_UNIX_SOCKET_PATH": "/custom/plugin.sock", - } - with patch.dict(os.environ, env_vars, clear=True): - with patch( - "cpex.framework.external.unix.server.runtime.run_server", - mock_run_server, - ): - await run() - - mock_run_server.assert_called_once_with( - config_path="/custom/config.yaml", - socket_path="/custom/plugin.sock", - ) - - -class TestUnixRuntimeMain: - """Tests for the main() CLI entry point.""" - - def test_main_keyboard_interrupt(self): - """Test main handles KeyboardInterrupt gracefully.""" - - def _raise_keyboard_interrupt(awaitable): - awaitable.close() - raise KeyboardInterrupt() - - with patch( - "cpex.framework.external.unix.server.runtime.asyncio.run", - side_effect=_raise_keyboard_interrupt, - ): - # Should not raise - main() - - def test_main_exception_exits(self): - """Test main exits with code 1 on exception.""" - - def _raise_runtime_error(awaitable): - awaitable.close() - raise RuntimeError("Server error") - - with patch( - "cpex.framework.external.unix.server.runtime.asyncio.run", - side_effect=_raise_runtime_error, - ): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 - - def test_main_calls_run(self): - """Test main calls asyncio.run with run().""" - captured = {} - - def _close_and_return(awaitable): - captured["awaitable"] = awaitable - awaitable.close() - return None - - with patch( - "cpex.framework.external.unix.server.runtime.asyncio.run", - ) as mock_asyncio_run: - mock_asyncio_run.side_effect = _close_and_return - main() - mock_asyncio_run.assert_called_once() - assert asyncio.iscoroutine(captured["awaitable"]) diff --git a/tests/unit/cpex/framework/external/unix/test_server.py b/tests/unit/cpex/framework/external/unix/test_server.py deleted file mode 100644 index 0d798226..00000000 --- a/tests/unit/cpex/framework/external/unix/test_server.py +++ /dev/null @@ -1,710 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/external/unix/test_server.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for Unix socket plugin server. -Tests for UnixSocketPluginServer message handling. -""" - -# Standard -import asyncio -import os -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import GlobalContext, PluginContext - -# Check if grpc/protobuf is available -try: - # Third-Party - from google.protobuf import json_format - from google.protobuf.struct_pb2 import Struct - - # First-Party - from cpex.framework.external.grpc.proto import plugin_service_pb2 - from cpex.framework.external.unix.server.server import UnixSocketPluginServer - - HAS_GRPC = True -except ImportError: - HAS_GRPC = False - json_format = None # type: ignore - Struct = None # type: ignore - -pytestmark = pytest.mark.skipif(not HAS_GRPC, reason="grpc not installed (required for protobuf)") - - -@pytest.fixture -def mock_plugin_server(): - """Create a mock ExternalPluginServer for testing.""" - mock_server = AsyncMock() - mock_server.get_plugin_configs = AsyncMock(return_value=[]) - mock_server.get_plugin_config = AsyncMock(return_value=None) - mock_server.invoke_hook = AsyncMock(return_value={"result": {"continue_processing": True}}) - mock_server.shutdown = AsyncMock() - return mock_server - - -@pytest.fixture -def server(tmp_path, mock_plugin_server): - """Create a UnixSocketPluginServer for testing.""" - socket_path = str(tmp_path / "test.sock") - srv = UnixSocketPluginServer( - config_path="tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml", - socket_path=socket_path, - ) - srv._plugin_server = mock_plugin_server - return srv - - -class TestUnixSocketPluginServerProperties: - """Tests for UnixSocketPluginServer properties.""" - - def test_socket_path(self, server): - """Test socket_path property returns correct path.""" - assert server.socket_path.endswith("test.sock") - - def test_running_initially_false(self, server): - """Test running property is False initially.""" - assert server.running is False - - -class TestUnixSocketPluginServerHandleMessage: - """Tests for UnixSocketPluginServer._handle_message.""" - - @pytest.mark.asyncio - async def test_handle_invoke_hook_request(self, server, mock_plugin_server): - """Test handling InvokeHookRequest message.""" - # Build request - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - response_bytes = await server._handle_message(request.SerializeToString()) - - # Parse response - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("result") - mock_plugin_server.invoke_hook.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_get_plugin_config_request_found(self, server, mock_plugin_server): - """Test handling GetPluginConfigRequest when plugin is found.""" - mock_plugin_server.get_plugin_config = AsyncMock( - return_value={ - "name": "TestPlugin", - "kind": "test.plugin.TestPlugin", - "hooks": ["tool_pre_invoke"], - } - ) - - request = plugin_service_pb2.GetPluginConfigRequest(name="TestPlugin") - response_bytes = await server._handle_message(request.SerializeToString()) - - response = plugin_service_pb2.GetPluginConfigResponse() - response.ParseFromString(response_bytes) - - assert response.found is True - config_dict = json_format.MessageToDict(response.config) - assert config_dict["name"] == "TestPlugin" - - @pytest.mark.asyncio - async def test_handle_get_plugin_config_request_not_found(self, server, mock_plugin_server): - """Test handling GetPluginConfigRequest when plugin is not found.""" - mock_plugin_server.get_plugin_config = AsyncMock(return_value=None) - - request = plugin_service_pb2.GetPluginConfigRequest(name="NonExistent") - response_bytes = await server._handle_message(request.SerializeToString()) - - response = plugin_service_pb2.GetPluginConfigResponse() - response.ParseFromString(response_bytes) - - assert response.found is False - - @pytest.mark.asyncio - async def test_handle_get_plugin_configs_request(self, server, mock_plugin_server): - """Test handling GetPluginConfigsRequest.""" - mock_plugin_server.get_plugin_configs = AsyncMock( - return_value=[ - {"name": "Plugin1", "kind": "test.Plugin1", "hooks": ["tool_pre_invoke"]}, - {"name": "Plugin2", "kind": "test.Plugin2", "hooks": ["prompt_pre_fetch"]}, - ] - ) - - request = plugin_service_pb2.GetPluginConfigsRequest() - response_bytes = await server._handle_message(request.SerializeToString()) - - response = plugin_service_pb2.GetPluginConfigsResponse() - response.ParseFromString(response_bytes) - - assert len(response.configs) == 2 - - -class TestUnixSocketPluginServerInvokeHook: - """Tests for UnixSocketPluginServer._handle_invoke_hook.""" - - @pytest.mark.asyncio - async def test_invoke_hook_success(self, server, mock_plugin_server): - """Test successful hook invocation returns result.""" - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": { - "continue_processing": True, - "modified_payload": None, - } - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - response_bytes = await server._handle_invoke_hook(request) - - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("result") - result_dict = json_format.MessageToDict(response.result) - assert result_dict.get("continueProcessing") is True or result_dict.get("continue_processing") is True - - @pytest.mark.asyncio - async def test_invoke_hook_with_error(self, server, mock_plugin_server): - """Test hook invocation error is returned in response.""" - from cpex.framework.errors import PluginError - from cpex.framework.models import PluginErrorModel - - mock_plugin_server.invoke_hook = AsyncMock( - side_effect=PluginError( - error=PluginErrorModel( - message="Processing failed", - plugin_name="TestPlugin", - code="PROCESSING_ERROR", - ) - ) - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - response_bytes = await server._handle_invoke_hook(request) - - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("error") - # The error message contains the PluginError string representation - assert "Processing failed" in response.error.message - - @pytest.mark.asyncio - async def test_invoke_hook_with_context_update(self, server, mock_plugin_server): - """Test hook invocation includes context updates.""" - result_context = PluginContext( - global_context=GlobalContext(request_id="test", server_id="test"), - state={"updated_key": "updated_value"}, - ) - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": {"continue_processing": True}, - "context": result_context, - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - response_bytes = await server._handle_invoke_hook(request) - - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("context") - - @pytest.mark.asyncio - async def test_invoke_hook_unexpected_error(self, server, mock_plugin_server): - """Test hook invocation handles unexpected exceptions.""" - mock_plugin_server.invoke_hook = AsyncMock(side_effect=RuntimeError("Unexpected error")) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - - request.context.global_context.request_id = "test-request" - request.context.global_context.server_id = "test-server" - - response_bytes = await server._handle_invoke_hook(request) - - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("error") - assert "Unexpected error" in response.error.message - - -class TestUnixSocketPluginServerLifecycle: - """Tests for UnixSocketPluginServer start/stop lifecycle.""" - - @pytest.mark.asyncio - async def test_start_creates_socket(self): - """Test start creates the Unix socket.""" - import os - import uuid - - # Use /tmp directly to avoid path length issues on macOS - short_id = uuid.uuid4().hex[:8] - socket_path = f"/tmp/unix-lifecycle-{short_id}.sock" - - try: - server = UnixSocketPluginServer( - config_path="tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml", - socket_path=socket_path, - ) - - with patch.object(server, "_plugin_server", AsyncMock()): - server._plugin_server.initialize = AsyncMock() - await server.start() - - assert server.running is True - assert os.path.exists(socket_path) - - await server.stop() - finally: - if os.path.exists(socket_path): - os.unlink(socket_path) - - @pytest.mark.asyncio - async def test_stop_cleans_up(self): - """Test stop cleans up resources.""" - import os - import uuid - - # Use /tmp directly to avoid path length issues on macOS - short_id = uuid.uuid4().hex[:8] - socket_path = f"/tmp/unix-cleanup-{short_id}.sock" - - try: - server = UnixSocketPluginServer( - config_path="tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml", - socket_path=socket_path, - ) - - with patch.object(server, "_plugin_server", AsyncMock()): - server._plugin_server.initialize = AsyncMock() - server._plugin_server.shutdown = AsyncMock() - await server.start() - await server.stop() - - assert server.running is False - # Socket file should be cleaned up - assert not os.path.exists(socket_path) - finally: - if os.path.exists(socket_path): - os.unlink(socket_path) - - @pytest.mark.asyncio - async def test_serve_forever_requires_start(self, server): - """Test serve_forever raises if server not started.""" - with pytest.raises(RuntimeError, match="Server not started"): - await server.serve_forever() - - @pytest.mark.asyncio - async def test_start_removes_existing_socket(self, tmp_path): - """Test start removes existing socket file before creating new one.""" - import uuid - - short_id = uuid.uuid4().hex[:8] - socket_path = f"/tmp/unix-existing-{short_id}.sock" - - try: - # Create an existing file at the socket path - with open(socket_path, "w") as f: - f.write("old socket") - assert os.path.exists(socket_path) - - server = UnixSocketPluginServer( - config_path="tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml", - socket_path=socket_path, - ) - - with patch.object(server, "_plugin_server", AsyncMock()): - server._plugin_server.initialize = AsyncMock() - await server.start() - - # Old file should have been replaced - assert server.running is True - await server.stop() - finally: - if os.path.exists(socket_path): - os.unlink(socket_path) - - @pytest.mark.asyncio - async def test_stop_handles_socket_cleanup_error(self, tmp_path): - """Test stop handles errors during socket file cleanup.""" - import uuid - - short_id = uuid.uuid4().hex[:8] - socket_path = f"/tmp/unix-cleanup-err-{short_id}.sock" - - try: - server = UnixSocketPluginServer( - config_path="tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml", - socket_path=socket_path, - ) - - with patch.object(server, "_plugin_server", AsyncMock()): - server._plugin_server.initialize = AsyncMock() - server._plugin_server.shutdown = AsyncMock() - await server.start() - - # Remove the socket file before stop so unlink fails gracefully - if os.path.exists(socket_path): - os.unlink(socket_path) - - # Re-create as a read-only dir to cause OSError on unlink - os.makedirs(socket_path, exist_ok=True) - - # Should not raise - await server.stop() - assert server.running is False - finally: - if os.path.exists(socket_path): - if os.path.isdir(socket_path): - os.rmdir(socket_path) - else: - os.unlink(socket_path) - - @pytest.mark.asyncio - async def test_stop_without_start(self): - """Test stop is safe when server was never started.""" - server = UnixSocketPluginServer( - config_path="test.yaml", - socket_path="/tmp/nonexistent.sock", - ) - # Should not raise - await server.stop() - assert server.running is False - - -class TestUnixSocketPluginServerHandleClient: - """Tests for UnixSocketPluginServer._handle_client.""" - - @pytest.mark.asyncio - async def test_handle_client_timeout(self, server, mock_plugin_server): - """Test _handle_client handles timeout gracefully.""" - - server._running = True - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.get_extra_info = MagicMock(return_value="test-peer") - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - - with patch( - "cpex.framework.external.unix.server.server.read_message", - side_effect=asyncio.TimeoutError(), - ): - await server._handle_client(mock_reader, mock_writer) - - mock_writer.close.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_client_incomplete_read(self, server, mock_plugin_server): - """Test _handle_client handles client disconnect.""" - server._running = True - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.get_extra_info = MagicMock(return_value="test-peer") - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - - with patch( - "cpex.framework.external.unix.server.server.read_message", - side_effect=asyncio.IncompleteReadError(b"", 4), - ): - await server._handle_client(mock_reader, mock_writer) - - mock_writer.close.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_client_protocol_error(self, server, mock_plugin_server): - """Test _handle_client handles protocol errors.""" - from cpex.framework.external.unix.protocol import ProtocolError - - server._running = True - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.get_extra_info = MagicMock(return_value="test-peer") - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - - with patch( - "cpex.framework.external.unix.server.server.read_message", - side_effect=ProtocolError("Bad message"), - ): - await server._handle_client(mock_reader, mock_writer) - - mock_writer.close.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_client_write_error(self, server, mock_plugin_server): - """Test _handle_client handles write errors during response.""" - server._running = True - - # First read succeeds, write fails - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - request.context.global_context.request_id = "test" - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.get_extra_info = MagicMock(return_value="test-peer") - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - - read_call_count = 0 - - async def mock_read(*args, **kwargs): - nonlocal read_call_count - read_call_count += 1 - if read_call_count == 1: - return request.SerializeToString() - raise asyncio.IncompleteReadError(b"", 4) - - with patch( - "cpex.framework.external.unix.server.server.read_message", - side_effect=mock_read, - ): - with patch( - "cpex.framework.external.unix.server.server.write_message_async", - side_effect=BrokenPipeError("Broken pipe"), - ): - await server._handle_client(mock_reader, mock_writer) - - mock_writer.close.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_client_unexpected_exception(self, server, mock_plugin_server): - """Test _handle_client handles unexpected exceptions.""" - server._running = True - - mock_reader = AsyncMock() - mock_writer = MagicMock() - mock_writer.get_extra_info = MagicMock(return_value="test-peer") - mock_writer.close = MagicMock() - mock_writer.wait_closed = AsyncMock() - - with patch( - "cpex.framework.external.unix.server.server.read_message", - side_effect=RuntimeError("Unexpected"), - ): - await server._handle_client(mock_reader, mock_writer) - - mock_writer.close.assert_called_once() - - -class TestUnixSocketPluginServerMessageHandling: - """Additional message handling tests for edge cases.""" - - @pytest.mark.asyncio - async def test_handle_message_unknown_type(self, server): - """Test _handle_message returns error for unknown message type.""" - # Send some random bytes that don't match any known message type - data = b"\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99" - - response_bytes = await server._handle_message(data) - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("error") - assert "Unknown message type" in response.error.message - - @pytest.mark.asyncio - async def test_handle_invoke_hook_with_error_dict(self, server, mock_plugin_server): - """Test _handle_invoke_hook handles error as raw dict.""" - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "error": { - "message": "Raw error", - "plugin_name": "TestPlugin", - "code": "ERR", - "mcp_error_code": -32603, - } - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - request.context.global_context.request_id = "test" - - response_bytes = await server._handle_invoke_hook(request) - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("error") - assert response.error.message == "Raw error" - - @pytest.mark.asyncio - async def test_handle_invoke_hook_with_error_model(self, server, mock_plugin_server): - """Test _handle_invoke_hook handles error as Pydantic model.""" - from cpex.framework.models import PluginErrorModel - - error_model = PluginErrorModel( - message="Model error", - plugin_name="TestPlugin", - code="MODEL_ERR", - ) - mock_plugin_server.invoke_hook = AsyncMock(return_value={"error": error_model}) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - request.context.global_context.request_id = "test" - - response_bytes = await server._handle_invoke_hook(request) - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("error") - assert response.error.message == "Model error" - - @pytest.mark.asyncio - async def test_handle_invoke_hook_with_dict_context(self, server, mock_plugin_server): - """Test _handle_invoke_hook handles context as plain dict.""" - mock_plugin_server.invoke_hook = AsyncMock( - return_value={ - "result": {"continue_processing": True}, - "context": { - "global_context": {"request_id": "req-1", "server_id": "srv-1"}, - "state": {"updated": True}, - }, - } - ) - - request = plugin_service_pb2.InvokeHookRequest() - request.hook_type = "tool_pre_invoke" - request.plugin_name = "TestPlugin" - - payload_struct = Struct() - json_format.ParseDict({"name": "test_tool", "args": {}}, payload_struct) - request.payload.CopyFrom(payload_struct) - request.context.global_context.request_id = "test" - - response_bytes = await server._handle_invoke_hook(request) - response = plugin_service_pb2.InvokeHookResponse() - response.ParseFromString(response_bytes) - - assert response.HasField("context") - - @pytest.mark.asyncio - async def test_handle_get_plugin_config_exception(self, server, mock_plugin_server): - """Test _handle_get_plugin_config handles exceptions.""" - mock_plugin_server.get_plugin_config = AsyncMock(side_effect=RuntimeError("DB error")) - - request = plugin_service_pb2.GetPluginConfigRequest(name="TestPlugin") - response_bytes = await server._handle_get_plugin_config(request) - - response = plugin_service_pb2.GetPluginConfigResponse() - response.ParseFromString(response_bytes) - - assert response.found is False - - @pytest.mark.asyncio - async def test_handle_get_plugin_configs_exception(self, server, mock_plugin_server): - """Test _handle_get_plugin_configs handles exceptions.""" - mock_plugin_server.get_plugin_configs = AsyncMock(side_effect=RuntimeError("DB error")) - - request = plugin_service_pb2.GetPluginConfigsRequest() - response_bytes = await server._handle_get_plugin_configs(request) - - response = plugin_service_pb2.GetPluginConfigsResponse() - response.ParseFromString(response_bytes) - - assert len(response.configs) == 0 - - -class TestUnixSocketRunServer: - """Tests for the run_server function.""" - - @pytest.mark.asyncio - async def test_run_server_lifecycle(self, tmp_path): - """Test run_server starts server and waits for signal.""" - from cpex.framework.external.unix.server.server import run_server - - socket_path = str(tmp_path / "test.sock") - - mock_server = AsyncMock() - mock_server.start = AsyncMock() - mock_server.stop = AsyncMock() - - stop_event = asyncio.Event() - stop_event.set() # Immediately signal to stop - - with patch( - "cpex.framework.external.unix.server.server.UnixSocketPluginServer", - return_value=mock_server, - ): - with patch( - "cpex.framework.external.unix.server.server.asyncio.Event", - return_value=stop_event, - ): - with patch("builtins.print"): - await run_server( - config_path="test.yaml", - socket_path=socket_path, - ) - - mock_server.start.assert_called_once() - mock_server.stop.assert_called_once() diff --git a/tests/unit/cpex/framework/hooks/test_hook_patterns.py b/tests/unit/cpex/framework/hooks/test_hook_patterns.py deleted file mode 100644 index 07e6b46d..00000000 --- a/tests/unit/cpex/framework/hooks/test_hook_patterns.py +++ /dev/null @@ -1,253 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/hooks/test_hook_patterns.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests demonstrating three hook patterns in the plugin framework: -1. Convention-based: method name matches hook type -2. Decorator-based: @hook decorator with custom method name -3. Custom hook: @hook decorator with new hook type + payload/result types -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - GlobalContext, - Plugin, - PluginContext, - PluginManager, - PluginPayload, - PluginResult, - ToolHookType, - ToolPostInvokePayload, - ToolPostInvokeResult, - ToolPreInvokePayload, - ToolPreInvokeResult, -) -from cpex.framework.decorator import hook - - -# ========== Custom Hook Definition ========== -class EmailPayload(PluginPayload): - """Payload for email hook.""" - - recipient: str - subject: str - body: str - - -class EmailResult(PluginResult[EmailPayload]): - """Result for email hook.""" - - pass - - -# ========== Demo Plugin with All Three Patterns ========== -class DemoPlugin(Plugin): - """Demo plugin showing all three hook patterns.""" - - # Pattern 1: Convention-based (method name matches hook type) - async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Pattern 1: Convention-based hook. - - This method is found automatically because its name matches - the hook type 'tool_pre_invoke'. - """ - # Modify the payload - modified_payload = ToolPreInvokePayload( - name=payload.name, - args={**payload.args, "pattern": "convention"}, - headers=payload.headers, - ) - - return ToolPreInvokeResult( - modified_payload=modified_payload, metadata={"pattern": "convention", "hook": "tool_pre_invoke"} - ) - - # Pattern 2: Decorator-based with custom method name - @hook(ToolHookType.TOOL_POST_INVOKE) - async def my_custom_tool_post_handler( - self, payload: ToolPostInvokePayload, context: PluginContext - ) -> ToolPostInvokeResult: - """Pattern 2: Decorator-based hook with custom method name. - - This method is found via the @hook decorator even though - the method name doesn't match the hook type. - """ - # Modify the result - modified_result = ( - {**payload.result, "pattern": "decorator"} if isinstance(payload.result, dict) else payload.result - ) - - modified_payload = ToolPostInvokePayload( - name=payload.name, - result=modified_result, - ) - - return ToolPostInvokeResult( - modified_payload=modified_payload, metadata={"pattern": "decorator", "hook": "tool_post_invoke"} - ) - - # Pattern 3: Custom hook with payload and result types - @hook("email_pre_send", EmailPayload, EmailResult) - async def validate_email(self, payload: EmailPayload, context: PluginContext) -> EmailResult: - """Pattern 3: Custom hook with new hook type. - - This registers a completely new hook type 'email_pre_send' - with its own payload and result types. - """ - # Validate email - if "@" not in payload.recipient: - modified_payload = EmailPayload( - recipient=f"{payload.recipient}@example.com", - subject=payload.subject, - body=payload.body, - ) - return EmailResult( - modified_payload=modified_payload, - metadata={"pattern": "custom", "hook": "email_pre_send", "fixed_email": True}, - ) - - return EmailResult(continue_processing=True, metadata={"pattern": "custom", "hook": "email_pre_send"}) - - -# ========== Pytest Tests ========== -@pytest.mark.asyncio -async def test_pattern_1_convention_based_hook(): - """Test Pattern 1: Convention-based hook (method name matches hook type).""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml") - await manager.initialize() - - # Create payload for tool_pre_invoke - payload = ToolPreInvokePayload(name="my_calculator", args={"operation": "add", "a": 5, "b": 3}) - - global_context = GlobalContext(request_id="test-1") - - # Invoke the hook - result, contexts = await manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, payload, global_context=global_context) - - # Assertions - assert result is not None - assert result.continue_processing is True - assert result.modified_payload is not None - assert result.modified_payload.name == "my_calculator" - assert result.modified_payload.args["operation"] == "add" - assert result.modified_payload.args["a"] == 5 - assert result.modified_payload.args["b"] == 3 - assert result.modified_payload.args["pattern"] == "convention" # Added by hook - assert result.metadata is not None - assert result.metadata["pattern"] == "convention" - assert result.metadata["hook"] == "tool_pre_invoke" - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_pattern_2_decorator_based_hook(): - """Test Pattern 2: Decorator-based hook with custom method name.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml") - await manager.initialize() - - # Create payload for tool_post_invoke - payload = ToolPostInvokePayload(name="my_calculator", result={"sum": 8, "status": "success"}) - - global_context = GlobalContext(request_id="test-2") - - # Invoke the hook - result, contexts = await manager.invoke_hook(ToolHookType.TOOL_POST_INVOKE, payload, global_context=global_context) - - # Assertions - assert result is not None - assert result.continue_processing is True - assert result.modified_payload is not None - assert result.modified_payload.name == "my_calculator" - assert result.modified_payload.result["sum"] == 8 - assert result.modified_payload.result["status"] == "success" - assert result.modified_payload.result["pattern"] == "decorator" # Added by hook - assert result.metadata is not None - assert result.metadata["pattern"] == "decorator" - assert result.metadata["hook"] == "tool_post_invoke" - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_pattern_3_custom_hook_valid_email(): - """Test Pattern 3: Custom hook with new hook type (valid email).""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml") - await manager.initialize() - - # Test with valid email - payload = EmailPayload(recipient="user@example.com", subject="Test Email", body="This is a test.") - - global_context = GlobalContext(request_id="test-3a") - - result, contexts = await manager.invoke_hook("email_pre_send", payload, global_context=global_context) - - # Assertions - assert result is not None - assert result.continue_processing is True - assert result.modified_payload is None # No modification needed for valid email - assert result.metadata is not None - assert result.metadata["pattern"] == "custom" - assert result.metadata["hook"] == "email_pre_send" - assert "fixed_email" not in result.metadata # Email was already valid - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_pattern_3_custom_hook_invalid_email(): - """Test Pattern 3: Custom hook with new hook type (invalid email gets fixed).""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml") - await manager.initialize() - - # Test with invalid email (missing @) - payload = EmailPayload(recipient="invalid-email", subject="Test Email 2", body="This email address needs fixing.") - - global_context = GlobalContext(request_id="test-3b") - - result, contexts = await manager.invoke_hook("email_pre_send", payload, global_context=global_context) - - # Assertions - assert result is not None - assert result.continue_processing is True - assert result.modified_payload is not None - assert result.modified_payload.recipient == "invalid-email@example.com" # Fixed by hook - assert result.modified_payload.subject == "Test Email 2" - assert result.modified_payload.body == "This email address needs fixing." - assert result.metadata is not None - assert result.metadata["pattern"] == "custom" - assert result.metadata["hook"] == "email_pre_send" - assert result.metadata["fixed_email"] is True # Hook fixed the email - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_all_three_patterns_in_sequence(): - """Test all three patterns work together in the same plugin manager.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/test_hook_patterns_config.yaml") - await manager.initialize() - - global_context = GlobalContext(request_id="test-all") - - # Test Pattern 1: Convention-based - payload1 = ToolPreInvokePayload(name="test_tool", args={"param": "value"}) - result1, _ = await manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, payload1, global_context=global_context) - assert result1.modified_payload.args["pattern"] == "convention" - - # Test Pattern 2: Decorator-based - payload2 = ToolPostInvokePayload(name="test_tool", result={"data": "output"}) - result2, _ = await manager.invoke_hook(ToolHookType.TOOL_POST_INVOKE, payload2, global_context=global_context) - assert result2.modified_payload.result["pattern"] == "decorator" - - # Test Pattern 3: Custom hook - payload3 = EmailPayload(recipient="test", subject="Test", body="Test") - result3, _ = await manager.invoke_hook("email_pre_send", payload3, global_context=global_context) - assert result3.modified_payload.recipient == "test@example.com" - - await manager.shutdown() diff --git a/tests/unit/cpex/framework/hooks/test_hook_registry.py b/tests/unit/cpex/framework/hooks/test_hook_registry.py deleted file mode 100644 index 1c7a8de7..00000000 --- a/tests/unit/cpex/framework/hooks/test_hook_registry.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright 2025 © IBM Corporation -SPDX-License-Identifier: Apache-2.0 - -Test suite for hook registry functionality. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - AgentHookType, - PromptHookType, - PromptPosthookPayload, - PromptPosthookResult, - PromptPrehookPayload, - PromptPrehookResult, - ResourceHookType, - ToolHookType, - ToolPreInvokePayload, - ToolPreInvokeResult, - get_hook_registry, -) - - -class TestHookRegistry: - """Test cases for the HookRegistry class.""" - - @pytest.fixture - def registry(self): - """Provide a hook registry instance.""" - return get_hook_registry() - - def test_mcp_hooks_are_registered(self, registry): - """Test that all MCP hooks are registered.""" - assert registry.is_registered(PromptHookType.PROMPT_PRE_FETCH) - assert registry.is_registered(PromptHookType.PROMPT_POST_FETCH) - assert registry.is_registered(ToolHookType.TOOL_PRE_INVOKE) - assert registry.is_registered(ToolHookType.TOOL_POST_INVOKE) - assert registry.is_registered(ResourceHookType.RESOURCE_PRE_FETCH) - assert registry.is_registered(ResourceHookType.RESOURCE_POST_FETCH) - - def test_get_payload_type(self, registry): - """Test retrieving payload types from registry.""" - payload_type = registry.get_payload_type(PromptHookType.PROMPT_PRE_FETCH) - assert payload_type == PromptPrehookPayload - - payload_type = registry.get_payload_type(PromptHookType.PROMPT_POST_FETCH) - assert payload_type == PromptPosthookPayload - - payload_type = registry.get_payload_type(ToolHookType.TOOL_PRE_INVOKE) - assert payload_type == ToolPreInvokePayload - - def test_get_result_type(self, registry): - """Test retrieving result types from registry.""" - result_type = registry.get_result_type(PromptHookType.PROMPT_PRE_FETCH) - assert result_type == PromptPrehookResult - - result_type = registry.get_result_type(PromptHookType.PROMPT_POST_FETCH) - assert result_type == PromptPosthookResult - - result_type = registry.get_result_type(ToolHookType.TOOL_PRE_INVOKE) - assert result_type == ToolPreInvokeResult - - def test_get_unregistered_hook_returns_none(self, registry): - """Test that unregistered hooks return None.""" - assert registry.get_payload_type("unknown_hook") is None - assert registry.get_result_type("unknown_hook") is None - assert not registry.is_registered("unknown_hook") - - def test_json_to_payload_with_dict(self, registry): - """Test converting dictionary to payload.""" - payload_dict = {"prompt_id": "test", "args": {"key": "value"}} - payload = registry.json_to_payload(PromptHookType.PROMPT_PRE_FETCH, payload_dict) - - assert isinstance(payload, PromptPrehookPayload) - assert payload.prompt_id == "test" - assert payload.args["key"] == "value" - - def test_json_to_payload_with_json_string(self, registry): - """Test converting JSON string to payload.""" - payload_json = '{"prompt_id": "test", "args": {"key": "value"}}' - payload = registry.json_to_payload(PromptHookType.PROMPT_PRE_FETCH, payload_json) - - assert isinstance(payload, PromptPrehookPayload) - assert payload.prompt_id == "test" - assert payload.args["key"] == "value" - - def test_json_to_result_with_dict(self, registry): - """Test converting dictionary to result.""" - result_dict = {"continue_processing": True, "modified_payload": None} - result = registry.json_to_result(PromptHookType.PROMPT_PRE_FETCH, result_dict) - - assert isinstance(result, PromptPrehookResult) - assert result.continue_processing is True - - def test_json_to_result_with_json_string(self, registry): - """Test converting JSON string to result.""" - result_json = '{"continue_processing": false, "modified_payload": null}' - result = registry.json_to_result(PromptHookType.PROMPT_PRE_FETCH, result_json) - - assert isinstance(result, PromptPrehookResult) - assert result.continue_processing is False - - def test_json_to_payload_unregistered_hook_raises_error(self, registry): - """Test that converting payload for unregistered hook raises ValueError.""" - with pytest.raises(ValueError, match="No payload type registered for hook"): - registry.json_to_payload("unknown_hook", {}) - - def test_json_to_result_unregistered_hook_raises_error(self, registry): - """Test that converting result for unregistered hook raises ValueError.""" - with pytest.raises(ValueError, match="No result type registered for hook"): - registry.json_to_result("unknown_hook", {}) - - def test_get_registered_hooks(self, registry): - """Test retrieving all registered hook types.""" - hooks = registry.get_registered_hooks() - - assert isinstance(hooks, list) - assert len(hooks) >= 8 # At least the 6 MCP hooks - assert PromptHookType.PROMPT_PRE_FETCH in hooks - assert PromptHookType.PROMPT_POST_FETCH in hooks - assert ToolHookType.TOOL_PRE_INVOKE in hooks - assert ToolHookType.TOOL_POST_INVOKE in hooks - assert ResourceHookType.RESOURCE_PRE_FETCH in hooks - assert ResourceHookType.RESOURCE_POST_FETCH in hooks - assert AgentHookType.AGENT_POST_INVOKE in hooks - assert AgentHookType.AGENT_PRE_INVOKE in hooks - - def test_registry_is_singleton(self): - """Test that get_hook_registry returns the same instance.""" - registry1 = get_hook_registry() - registry2 = get_hook_registry() - - assert registry1 is registry2 diff --git a/tests/unit/cpex/framework/hooks/test_http.py b/tests/unit/cpex/framework/hooks/test_http.py deleted file mode 100644 index 7bf76667..00000000 --- a/tests/unit/cpex/framework/hooks/test_http.py +++ /dev/null @@ -1,562 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for HTTP forwarding hooks. - -This module tests the HTTP forwarding hook models and their behavior. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework.hooks.http import ( - HttpAuthResolveUserPayload, - HttpHeaderPayload, - HttpHookType, - HttpPostRequestPayload, - HttpPreRequestPayload, -) -from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.models import PluginResult - - -class TestHttpHookType: - """Test HttpHookType enum.""" - - def test_hook_type_values(self): - """Test that hook types have correct string values.""" - assert HttpHookType.HTTP_PRE_REQUEST == "http_pre_request" - assert HttpHookType.HTTP_POST_REQUEST == "http_post_request" - assert HttpHookType.HTTP_AUTH_RESOLVE_USER == "http_auth_resolve_user" - - def test_hook_type_from_string(self): - """Test creating hook types from string values.""" - assert HttpHookType("http_pre_request") == HttpHookType.HTTP_PRE_REQUEST - assert HttpHookType("http_post_request") == HttpHookType.HTTP_POST_REQUEST - assert HttpHookType("http_auth_resolve_user") == HttpHookType.HTTP_AUTH_RESOLVE_USER - - def test_hook_types_list(self): - """Test getting list of all hook types.""" - hook_types = list(HttpHookType) - assert len(hook_types) == 4 - assert HttpHookType.HTTP_PRE_REQUEST in hook_types - assert HttpHookType.HTTP_POST_REQUEST in hook_types - assert HttpHookType.HTTP_AUTH_RESOLVE_USER in hook_types - - -class TestHttpHeaderPayload: - """Test HttpHeaderPayload model.""" - - def test_create_header_payload(self): - """Test creating an HttpHeaderPayload.""" - headers = HttpHeaderPayload({"Authorization": "Bearer token123", "Content-Type": "application/json"}) - assert headers["Authorization"] == "Bearer token123" - assert headers["Content-Type"] == "application/json" - - def test_header_payload_iteration(self): - """Test iterating over headers.""" - headers = HttpHeaderPayload({"X-Custom": "value1", "X-Another": "value2"}) - keys = list(headers) - assert "X-Custom" in keys - assert "X-Another" in keys - - def test_header_payload_setitem(self): - """Test setting header values.""" - headers = HttpHeaderPayload({"Initial": "value"}) - headers["New-Header"] = "new-value" - assert headers["New-Header"] == "new-value" - - def test_header_payload_len(self): - """Test getting length of headers.""" - headers = HttpHeaderPayload({"Header1": "value1", "Header2": "value2", "Header3": "value3"}) - assert len(headers) == 3 - - def test_empty_header_payload(self): - """Test creating an empty header payload.""" - headers = HttpHeaderPayload({}) - assert len(headers) == 0 - - -class TestHttpPreRequestPayload: - """Test HttpPreRequestPayload model.""" - - def test_create_pre_request_payload(self): - """Test creating a pre-request payload.""" - headers = HttpHeaderPayload({"Authorization": "Bearer token"}) - payload = HttpPreRequestPayload( - path="/api/v1/test", - method="POST", - client_host="192.168.1.1", - client_port=12345, - headers=headers, - ) - - assert payload.path == "/api/v1/test" - assert payload.method == "POST" - assert payload.client_host == "192.168.1.1" - assert payload.client_port == 12345 - assert payload.headers["Authorization"] == "Bearer token" - - def test_pre_request_payload_with_optional_fields_none(self): - """Test creating payload with optional fields as None.""" - headers = HttpHeaderPayload({}) - payload = HttpPreRequestPayload( - path="/forward", - method="GET", - headers=headers, - ) - - assert payload.client_host is None - assert payload.client_port is None - - def test_pre_request_payload_different_methods(self): - """Test payload with different HTTP methods.""" - headers = HttpHeaderPayload({}) - - for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]: - payload = HttpPreRequestPayload( - path="/api/test", - method=method, - headers=headers, - ) - assert payload.method == method - - def test_pre_request_payload_serialization(self): - """Test payload serialization to dict.""" - headers = HttpHeaderPayload({"X-Custom": "value"}) - payload = HttpPreRequestPayload( - path="/test", - method="POST", - client_host="10.0.0.1", - client_port=8080, - headers=headers, - ) - - data = payload.model_dump() - assert data["path"] == "/test" - assert data["method"] == "POST" - assert data["client_host"] == "10.0.0.1" - assert data["client_port"] == 8080 - - def test_pre_request_payload_json_serialization(self): - """Test payload serialization to JSON.""" - headers = HttpHeaderPayload({"Authorization": "Bearer token"}) - payload = HttpPreRequestPayload( - path="/api", - method="GET", - headers=headers, - ) - - json_str = payload.model_dump_json() - assert "/api" in json_str - assert "GET" in json_str - - -class TestHttpPostRequestPayload: - """Test HttpPostRequestPayload model.""" - - def test_create_post_request_payload(self): - """Test creating a post-request payload.""" - headers = HttpHeaderPayload({"Authorization": "Bearer token"}) - response_headers = HttpHeaderPayload({"Content-Type": "application/json", "X-Request-ID": "abc123"}) - - payload = HttpPostRequestPayload( - path="/api/v1/test", - method="POST", - client_host="192.168.1.1", - client_port=12345, - headers=headers, - response_headers=response_headers, - status_code=200, - ) - - assert payload.path == "/api/v1/test" - assert payload.method == "POST" - assert payload.client_host == "192.168.1.1" - assert payload.client_port == 12345 - assert payload.headers["Authorization"] == "Bearer token" - assert payload.response_headers["Content-Type"] == "application/json" - assert payload.response_headers["X-Request-ID"] == "abc123" - assert payload.status_code == 200 - - def test_post_request_payload_without_response(self): - """Test creating post-request payload without response data.""" - headers = HttpHeaderPayload({}) - payload = HttpPostRequestPayload( - path="/test", - method="GET", - headers=headers, - ) - - assert payload.response_headers is None - assert payload.status_code is None - - def test_post_request_payload_inherits_from_pre(self): - """Test that HttpPostRequestPayload inherits from HttpPreRequestPayload.""" - headers = HttpHeaderPayload({}) - payload = HttpPostRequestPayload( - path="/test", - method="GET", - headers=headers, - status_code=404, - ) - - # Check inheritance - assert isinstance(payload, HttpPreRequestPayload) - - def test_post_request_payload_various_status_codes(self): - """Test payload with various HTTP status codes.""" - headers = HttpHeaderPayload({}) - - for status_code in [200, 201, 204, 400, 401, 403, 404, 500, 502, 503]: - payload = HttpPostRequestPayload( - path="/test", - method="GET", - headers=headers, - status_code=status_code, - ) - assert payload.status_code == status_code - - def test_post_request_payload_serialization(self): - """Test post-request payload serialization.""" - headers = HttpHeaderPayload({"X-Request": "test"}) - response_headers = HttpHeaderPayload({"X-Response": "result"}) - - payload = HttpPostRequestPayload( - path="/api/test", - method="POST", - client_host="127.0.0.1", - client_port=9000, - headers=headers, - response_headers=response_headers, - status_code=201, - ) - - data = payload.model_dump() - assert data["path"] == "/api/test" - assert data["status_code"] == 201 - - -class TestHttpAuthResolveUserPayload: - """Test HttpAuthResolveUserPayload model.""" - - def test_create_auth_resolve_payload_with_credentials(self): - """Test creating auth resolve payload with credentials.""" - headers = HttpHeaderPayload({"X-Custom-Auth": "custom-token-123", "User-Agent": "TestClient/1.0"}) - credentials = {"scheme": "bearer", "credentials": "jwt-token-abc"} - - payload = HttpAuthResolveUserPayload( - credentials=credentials, - headers=headers, - client_host="10.0.0.5", - client_port=54321, - ) - - assert payload.credentials == credentials - assert payload.headers["X-Custom-Auth"] == "custom-token-123" - assert payload.headers["User-Agent"] == "TestClient/1.0" - assert payload.client_host == "10.0.0.5" - assert payload.client_port == 54321 - - def test_create_auth_resolve_payload_without_credentials(self): - """Test creating auth resolve payload without credentials (custom header auth).""" - headers = HttpHeaderPayload({"X-API-Key": "secret-key-456", "X-Client-ID": "client-789"}) - - payload = HttpAuthResolveUserPayload( - credentials=None, - headers=headers, - client_host="192.168.1.100", - ) - - assert payload.credentials is None - assert payload.headers["X-API-Key"] == "secret-key-456" - assert payload.headers["X-Client-ID"] == "client-789" - assert payload.client_host == "192.168.1.100" - assert payload.client_port is None - - def test_auth_resolve_payload_with_mtls_cert_header(self): - """Test auth resolve payload with mTLS certificate header.""" - headers = HttpHeaderPayload( - { - "X-SSL-Client-Cert": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", - "X-SSL-Client-DN": "CN=user@example.com,O=Example Corp", - } - ) - - payload = HttpAuthResolveUserPayload( - credentials=None, - headers=headers, - client_host="172.16.0.50", - client_port=443, - ) - - assert "X-SSL-Client-Cert" in payload.headers - assert "X-SSL-Client-DN" in payload.headers - assert payload.client_port == 443 - - def test_auth_resolve_payload_with_ldap_token(self): - """Test auth resolve payload with LDAP token header.""" - headers = HttpHeaderPayload({"X-LDAP-Token": "ldap-session-xyz123"}) - - payload = HttpAuthResolveUserPayload( - credentials=None, - headers=headers, - ) - - assert payload.headers["X-LDAP-Token"] == "ldap-session-xyz123" - - def test_auth_resolve_payload_serialization(self): - """Test auth resolve payload serialization.""" - headers = HttpHeaderPayload({"Authorization": "Bearer token"}) - credentials = {"scheme": "bearer", "credentials": "token"} - - payload = HttpAuthResolveUserPayload( - credentials=credentials, - headers=headers, - client_host="127.0.0.1", - ) - - data = payload.model_dump() - assert data["credentials"] == credentials - assert data["client_host"] == "127.0.0.1" - - def test_auth_resolve_payload_json_serialization(self): - """Test auth resolve payload JSON serialization.""" - headers = HttpHeaderPayload({"X-Auth": "custom"}) - - payload = HttpAuthResolveUserPayload( - credentials=None, - headers=headers, - ) - - json_str = payload.model_dump_json() - assert "X-Auth" in json_str - assert "custom" in json_str - - -class TestHttpResults: - """Test HTTP result type aliases.""" - - def test_pre_request_result_type(self): - """Test HttpPreRequestResult is a PluginResult.""" - headers = HttpHeaderPayload({"Modified": "header"}) - result = PluginResult[HttpHeaderPayload]( - continue_processing=True, - modified_payload=headers, - ) - - assert result.continue_processing is True - assert result.modified_payload["Modified"] == "header" - - def test_post_request_result_type(self): - """Test HttpPostRequestResult is a PluginResult.""" - headers = HttpHeaderPayload({"X-Added": "value"}) - result = PluginResult[HttpHeaderPayload]( - continue_processing=True, - modified_payload=headers, - metadata={"plugin": "auth_plugin"}, - ) - - assert result.continue_processing is True - assert result.modified_payload["X-Added"] == "value" - assert result.metadata["plugin"] == "auth_plugin" - - def test_auth_resolve_user_result_type(self): - """Test HttpAuthResolveUserResult returns user dict.""" - user_dict = { - "email": "user@example.com", - "full_name": "Test User", - "is_admin": False, - "is_active": True, - } - - result = PluginResult[dict]( - continue_processing=False, # Stop processing, user authenticated - modified_payload=user_dict, - ) - - assert result.continue_processing is False - assert result.modified_payload["email"] == "user@example.com" - assert result.modified_payload["full_name"] == "Test User" - assert result.modified_payload["is_admin"] is False - - def test_result_with_violation(self): - """Test result with a violation (blocking).""" - from cpex.framework.models import PluginViolation - - violation = PluginViolation( - reason="Unauthorized", - description="Missing authentication token", - code="AUTH_REQUIRED", - ) - - result = PluginResult[HttpHeaderPayload]( - continue_processing=False, - violation=violation, - ) - - assert result.continue_processing is False - assert result.violation is not None - assert result.violation.code == "AUTH_REQUIRED" - - -class TestHttpHookRegistry: - """Test HTTP hooks registration in the hook registry.""" - - def test_hooks_are_registered(self): - """Test that all HTTP hooks are registered.""" - registry = get_hook_registry() - - assert registry.is_registered(HttpHookType.HTTP_PRE_REQUEST) - assert registry.is_registered(HttpHookType.HTTP_POST_REQUEST) - assert registry.is_registered(HttpHookType.HTTP_AUTH_RESOLVE_USER) - - def test_pre_request_hook_payload_type(self): - """Test that pre-request hook has correct payload type.""" - registry = get_hook_registry() - - payload_type = registry.get_payload_type(HttpHookType.HTTP_PRE_REQUEST) - assert payload_type is HttpPreRequestPayload - - def test_post_request_hook_payload_type(self): - """Test that post-request hook has correct payload type.""" - registry = get_hook_registry() - - payload_type = registry.get_payload_type(HttpHookType.HTTP_POST_REQUEST) - assert payload_type is HttpPostRequestPayload - - def test_auth_resolve_user_hook_payload_type(self): - """Test that auth resolve user hook has correct payload type.""" - registry = get_hook_registry() - - payload_type = registry.get_payload_type(HttpHookType.HTTP_AUTH_RESOLVE_USER) - assert payload_type is HttpAuthResolveUserPayload - - def test_pre_request_hook_result_type(self): - """Test that pre-request hook has correct result type.""" - registry = get_hook_registry() - - result_type = registry.get_result_type(HttpHookType.HTTP_PRE_REQUEST) - assert result_type is not None - - def test_post_request_hook_result_type(self): - """Test that post-request hook has correct result type.""" - registry = get_hook_registry() - - result_type = registry.get_result_type(HttpHookType.HTTP_POST_REQUEST) - assert result_type is not None - - def test_auth_resolve_user_hook_result_type(self): - """Test that auth resolve user hook has correct result type.""" - registry = get_hook_registry() - - result_type = registry.get_result_type(HttpHookType.HTTP_AUTH_RESOLVE_USER) - assert result_type is not None - - -class TestHttpPayloadImmutability: - """Test that payload metadata fields are effectively read-only (Option 3 design).""" - - def test_payload_fields_are_set_at_creation(self): - """Test that all payload fields are set during creation.""" - headers = HttpHeaderPayload({"X-Test": "value"}) - payload = HttpPreRequestPayload( - path="/api/test", - method="POST", - client_host="10.0.0.1", - client_port=8080, - headers=headers, - ) - - # All fields should be accessible - assert payload.path == "/api/test" - assert payload.method == "POST" - assert payload.client_host == "10.0.0.1" - assert payload.client_port == 8080 - - def test_headers_can_be_modified(self): - """Test that headers can be modified (the plugin's job).""" - headers = HttpHeaderPayload({"Original": "value"}) - payload = HttpPreRequestPayload( - path="/test", - method="GET", - headers=headers, - ) - - # Headers should be modifiable - payload.headers["New-Header"] = "new-value" - assert payload.headers["New-Header"] == "new-value" - - def test_plugin_returns_modified_headers_only(self): - """Test plugin pattern: return only modified headers in result.""" - # This simulates a plugin receiving a payload and returning modified headers - original_headers = HttpHeaderPayload({"Content-Type": "application/json"}) - payload = HttpPreRequestPayload( - path="/api/secure", - method="POST", - headers=original_headers, - ) - - # Plugin modifies headers using model_dump() - modified_headers = HttpHeaderPayload(payload.headers.model_dump()) - modified_headers["Authorization"] = "Bearer plugin-added-token" - - # Plugin returns result with only the modified headers - result = PluginResult[HttpHeaderPayload]( - continue_processing=True, - modified_payload=modified_headers, - ) - - # Framework would apply these headers to the request - assert result.modified_payload["Authorization"] == "Bearer plugin-added-token" - assert result.modified_payload["Content-Type"] == "application/json" - - -class TestHttpPayloadEdgeCases: - """Test edge cases for HTTP forwarding payloads.""" - - def test_empty_path(self): - """Test payload with empty path.""" - headers = HttpHeaderPayload({}) - payload = HttpPreRequestPayload( - path="", - method="GET", - headers=headers, - ) - assert payload.path == "" - - def test_very_long_path(self): - """Test payload with very long path.""" - headers = HttpHeaderPayload({}) - long_path = "/api/v1/" + "segment/" * 100 + "endpoint" - payload = HttpPreRequestPayload( - path=long_path, - method="GET", - headers=headers, - ) - assert payload.path == long_path - - def test_large_number_of_headers(self): - """Test payload with many headers.""" - headers_dict = {f"X-Header-{i}": f"value-{i}" for i in range(100)} - headers = HttpHeaderPayload(headers_dict) - payload = HttpPreRequestPayload( - path="/test", - method="GET", - headers=headers, - ) - assert len(payload.headers) == 100 - - def test_ipv6_client_host(self): - """Test payload with IPv6 client host.""" - headers = HttpHeaderPayload({}) - payload = HttpPreRequestPayload( - path="/test", - method="GET", - client_host="2001:0db8:85a3:0000:0000:8a2e:0370:7334", - headers=headers, - ) - assert payload.client_host == "2001:0db8:85a3:0000:0000:8a2e:0370:7334" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/unit/cpex/framework/hooks/test_identity.py b/tests/unit/cpex/framework/hooks/test_identity.py deleted file mode 100644 index 5541f8e6..00000000 --- a/tests/unit/cpex/framework/hooks/test_identity.py +++ /dev/null @@ -1,159 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for identity resolution and token delegation hook payloads. - -Covers: -- IdentityPayload construction and SecretStr redaction -- DelegationPayload construction and SecretStr redaction -- AttenuationConfig construction -- IdentityResult construction -- Serialization safety (tokens redacted in JSON output) -""" - -import pytest -from pydantic import SecretStr - -from cpex.framework.extensions.security import SubjectExtension, SubjectType -from cpex.framework.hooks.identity import ( - AttenuationConfig, - DelegationPayload, - IdentityPayload, - IdentityResult, -) - - -class TestIdentityPayload: - """Tests for IdentityPayload.""" - - def test_basic_construction(self): - payload = IdentityPayload( - raw_token="eyJhbGciOi...", - source="bearer", - ) - assert payload.source == "bearer" - assert isinstance(payload.raw_token, SecretStr) - - def test_raw_token_is_secret(self): - payload = IdentityPayload(raw_token="secret-jwt", source="bearer") - # str() should redact - assert "secret-jwt" not in str(payload.raw_token) - # get_secret_value() reveals it - assert payload.raw_token.get_secret_value() == "secret-jwt" - - def test_serialization_redacts_token(self): - payload = IdentityPayload(raw_token="secret-jwt", source="bearer") - dumped = payload.model_dump() - # The serialized value should not contain the actual token - assert dumped["raw_token"] != "secret-jwt" - - def test_with_headers(self): - payload = IdentityPayload( - raw_token="tok", - source="bearer", - headers={"authorization": "Bearer tok"}, - client_host="10.0.0.1", - client_port=443, - ) - assert payload.headers["authorization"] == "Bearer tok" - assert payload.client_host == "10.0.0.1" - - def test_default_source(self): - payload = IdentityPayload(raw_token="tok") - assert payload.source == "bearer" - - -class TestDelegationPayload: - """Tests for DelegationPayload.""" - - def test_basic_construction(self): - payload = DelegationPayload( - target_name="get_compensation", - target_type="tool", - required_permissions=["read:compensation"], - ) - assert payload.target_name == "get_compensation" - assert payload.target_type == "tool" - assert payload.bearer_token is None - - def test_bearer_token_is_secret(self): - payload = DelegationPayload( - target_name="get_compensation", - bearer_token="my-bearer-token", - ) - assert isinstance(payload.bearer_token, SecretStr) - assert "my-bearer-token" not in str(payload.bearer_token) - assert payload.bearer_token.get_secret_value() == "my-bearer-token" - - def test_serialization_redacts_bearer(self): - payload = DelegationPayload( - target_name="tool", - bearer_token="secret-bearer", - ) - dumped = payload.model_dump() - assert dumped["bearer_token"] != "secret-bearer" - - def test_with_attenuation(self): - attenuation = AttenuationConfig( - capabilities=["read:compensation"], - resource_template="hr://employees/{{ args.employee_id }}", - actions=["read"], - ttl_seconds=60, - ) - payload = DelegationPayload( - target_name="get_compensation", - auth_enforced_by="target", - route_attenuation=attenuation, - ) - assert payload.route_attenuation.capabilities == ["read:compensation"] - assert payload.route_attenuation.ttl_seconds == 60 - - -class TestAttenuationConfig: - """Tests for AttenuationConfig.""" - - def test_basic_construction(self): - config = AttenuationConfig( - capabilities=["read:compensation"], - actions=["read"], - ) - assert config.capabilities == ["read:compensation"] - assert config.actions == ["read"] - assert config.resource_template is None - assert config.ttl_seconds is None - - def test_frozen(self): - config = AttenuationConfig(capabilities=["read"]) - with pytest.raises(Exception): - config.capabilities = ["write"] - - def test_defaults(self): - config = AttenuationConfig() - assert config.capabilities == [] - assert config.actions == [] - assert config.resource_template is None - assert config.ttl_seconds is None - - -class TestIdentityResult: - """Tests for IdentityResult.""" - - def test_accepted_result(self): - subject = SubjectExtension( - id="alice@corp.com", - type=SubjectType.USER, - roles=frozenset({"engineer"}), - permissions=frozenset({"tool_execute"}), - ) - result = IdentityResult(subject=subject) - assert result.rejected is False - assert result.subject.id == "alice@corp.com" - - def test_rejected_result(self): - result = IdentityResult( - rejected=True, - reject_status=401, - reject_reason="Token expired", - ) - assert result.rejected is True - assert result.reject_status == 401 - assert result.reject_reason == "Token expired" - assert result.subject is None diff --git a/tests/unit/cpex/framework/hooks/test_message.py b/tests/unit/cpex/framework/hooks/test_message.py deleted file mode 100644 index 8c97f510..00000000 --- a/tests/unit/cpex/framework/hooks/test_message.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/hooks/test_message.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for message evaluation hook definitions. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework.cmf.message import Message, Role, TextContent -from cpex.framework.hooks.message import ( - MessageHookType, - MessagePayload, - MessageResult, -) -from cpex.framework.hooks.registry import get_hook_registry -from cpex.framework.models import PluginPayload, PluginResult - -# --------------------------------------------------------------------------- -# MessageHookType Tests -# --------------------------------------------------------------------------- - - -class TestMessageHookType: - """Tests for the MessageHookType enum.""" - - def test_evaluate_value(self): - assert MessageHookType.EVALUATE.value == "evaluate" - - def test_from_string(self): - assert MessageHookType("evaluate") == MessageHookType.EVALUATE - - def test_invalid_value(self): - with pytest.raises(ValueError): - MessageHookType("invalid") - - def test_member_count(self): - assert len(MessageHookType) == 9 - - def test_is_str_enum(self): - assert isinstance(MessageHookType.EVALUATE, str) - assert MessageHookType.EVALUATE == "evaluate" - - -# --------------------------------------------------------------------------- -# MessagePayload Tests -# --------------------------------------------------------------------------- - - -class TestMessagePayload: - """Tests for the MessagePayload model.""" - - def test_subclass_of_plugin_payload(self): - assert issubclass(MessagePayload, PluginPayload) - - def test_creation(self): - msg = Message( - role=Role.USER, - content=[TextContent(text="Hello")], - ) - payload = MessagePayload(message=msg) - assert payload.message is msg - assert payload.message.role == Role.USER - assert payload.message.content[0].text == "Hello" - - def test_message_field_required(self): - with pytest.raises(Exception): - MessagePayload() - - def test_with_multi_part_message(self): - msg = Message( - role=Role.ASSISTANT, - content=[ - TextContent(text="Part one"), - TextContent(text="Part two"), - ], - ) - payload = MessagePayload(message=msg) - assert len(payload.message.content) == 2 - - def test_iter_views_through_payload(self): - msg = Message( - role=Role.USER, - content=[ - TextContent(text="First"), - TextContent(text="Second"), - ], - ) - payload = MessagePayload(message=msg) - views = list(payload.message.iter_views()) - assert len(views) == 2 - - -# --------------------------------------------------------------------------- -# MessageResult Tests -# --------------------------------------------------------------------------- - - -class TestMessageResult: - """Tests for the MessageResult type alias.""" - - def test_is_plugin_result_subclass(self): - assert issubclass(MessageResult, PluginResult) - - -# --------------------------------------------------------------------------- -# Hook Registration Tests -# --------------------------------------------------------------------------- - - -class TestMessageHookRegistration: - """Tests for message hook registration in the global registry.""" - - def test_evaluate_hook_registered(self): - registry = get_hook_registry() - assert registry.is_registered(MessageHookType.EVALUATE) - - def test_payload_type(self): - registry = get_hook_registry() - assert registry.get_payload_type(MessageHookType.EVALUATE) is MessagePayload - - def test_result_type(self): - registry = get_hook_registry() - assert registry.get_result_type(MessageHookType.EVALUATE) is MessageResult - - def test_idempotent_registration(self): - """Re-importing or re-calling _register should not raise.""" - # First-Party - from cpex.framework.hooks.message import _register_message_hooks - - _register_message_hooks() - registry = get_hook_registry() - assert registry.is_registered(MessageHookType.EVALUATE) diff --git a/tests/unit/cpex/framework/isolated/README.md b/tests/unit/cpex/framework/isolated/README.md deleted file mode 100644 index 162e5489..00000000 --- a/tests/unit/cpex/framework/isolated/README.md +++ /dev/null @@ -1,199 +0,0 @@ -# Isolated Plugin Framework Tests - -This directory contains comprehensive unit and integration tests for the isolated plugin framework, which enables running plugins in separate Python virtual environments. - -## Overview - -The isolated plugin framework consists of three main components: - -1. **VenvProcessCommunicator** (`venv_comm.py`) - Handles communication with child processes in different virtual environments -2. **IsolatedVenvPlugin** (`client.py`) - Plugin client that manages venv-isolated plugins -3. **Worker** (`worker.py`) - Worker process that runs inside the venv and executes plugin hooks - -## Test Files - -### `test_venv_comm.py` -Tests for the `VenvProcessCommunicator` class that handles inter-process communication. - -**Coverage:** -- Virtual environment path validation (Unix/Windows) -- Python executable detection -- Requirements installation (success/failure cases) -- Task sending and response handling -- Error handling (timeouts, invalid JSON, process failures) -- Complex data serialization -- Working directory maintenance - -**Key Test Cases:** -- `test_init_valid_venv` - Validates proper initialization with valid venv -- `test_send_task_success` - Tests successful task execution -- `test_send_task_timeout` - Tests timeout handling -- `test_install_requirements_success` - Tests pip installation - -### `test_client.py` -Tests for the `IsolatedVenvPlugin` class that serves as the plugin client. - -**Coverage:** -- Plugin initialization and configuration -- Virtual environment creation -- Hook invocation for all hook types (tool_pre_invoke, tool_post_invoke, prompt_pre_fetch, prompt_post_fetch) -- Payload and context serialization -- Error handling (PluginError, generic exceptions) -- Policy violation handling -- Safe config generation - -**Key Test Cases:** -- `test_invoke_hook_tool_pre_invoke_success` - Tests tool pre-invoke hook -- `test_invoke_hook_with_violation` - Tests policy violation handling -- `test_invoke_hook_plugin_error` - Tests PluginError propagation -- `test_invoke_hook_serialization` - Tests proper data serialization - -### `test_worker.py` -Tests for the worker process functions that execute inside the venv. - -**Coverage:** -- Environment information retrieval -- Plugin configuration loading -- Task processing (info, load_and_run_hook) -- Plugin loading and instantiation -- Hook execution -- Error handling (import errors, missing configs) -- Multiple hook type support -- sys.path modification - -**Key Test Cases:** -- `test_get_environment_info` - Tests environment info collection -- `test_process_task_load_and_run_hook_success` - Tests successful hook execution -- `test_process_task_with_different_hook_types` - Tests all hook types -- `test_process_task_import_error` - Tests import error handling - -### `test_integration.py` -Integration tests that verify the entire isolated plugin system working together. - -**Coverage:** -- Full plugin lifecycle (initialization → hook invocation → cleanup) -- PluginManager integration with isolated plugins -- Context propagation through the isolation boundary -- Multiple hook type execution -- Policy violation handling end-to-end -- Error handling across process boundaries - -**Key Test Cases:** -- `test_isolated_plugin_full_lifecycle` - Tests complete plugin lifecycle -- `test_isolated_plugin_context_propagation` - Tests context serialization -- `test_isolated_plugin_with_multiple_hooks` - Tests multiple hook types -- `test_isolated_plugin_violation_handling` - Tests violation propagation - -### `conftest.py` -Pytest fixtures shared across all isolated plugin tests. - -**Fixtures:** -- `mock_venv_structure` - Creates mock venv directory structure -- `sample_plugin_config` - Provides sample plugin configuration -- `sample_global_context` - Creates test GlobalContext -- `sample_plugin_context` - Creates test PluginContext -- `mock_communicator` - Provides mock VenvProcessCommunicator -- `sample_requirements_file` - Creates test requirements.txt - -## Running the Tests - -### Run all isolated plugin tests: -```bash -pytest tests/unit/cpex/framework/isolated/ -``` - -### Run specific test file: -```bash -pytest tests/unit/cpex/framework/isolated/test_venv_comm.py -``` - -### Run with coverage: -```bash -pytest tests/unit/cpex/framework/isolated/ --cov=cpex.framework.isolated --cov-report=html -``` - -### Run specific test: -```bash -pytest tests/unit/cpex/framework/isolated/test_client.py::TestIsolatedVenvPlugin::test_invoke_hook_tool_pre_invoke_success -``` - -## Test Architecture - -### Mocking Strategy -The tests use extensive mocking to avoid: -- Creating actual virtual environments (slow and resource-intensive) -- Installing real packages via pip -- Spawning actual subprocesses -- File system operations where possible - -### Fixtures -Common test fixtures are defined in `conftest.py` to promote code reuse and consistency across tests. - -### Test Organization -Tests are organized by component: -- **Unit tests** - Test individual functions and methods in isolation -- **Integration tests** - Test components working together - -## Coverage Goals - -The test suite aims for: -- **Line coverage**: >90% -- **Branch coverage**: >85% -- **Function coverage**: 100% - -## Key Testing Patterns - -### 1. Async Testing -```python -@pytest.mark.asyncio -async def test_async_function(): - result = await some_async_function() - assert result is not None -``` - -### 2. Mock Subprocess Communication -```python -@patch("subprocess.Popen") -def test_send_task(mock_popen): - mock_process = MagicMock() - mock_process.communicate.return_value = ('{"status": "ok"}', "") - mock_popen.return_value = mock_process - # Test code here -``` - -### 3. Context Propagation Testing -```python -def test_context_propagation(): - # Create context with specific data - context = PluginContext(global_context=GlobalContext(...)) - # Invoke hook - result = await plugin.invoke_hook(hook_type, payload, context) - # Verify context was properly serialized and sent -``` - -## Common Issues and Solutions - -### Issue: Tests fail with "Python executable not found" -**Solution**: Ensure mock_venv_structure fixture is being used, which creates the proper directory structure. - -### Issue: Async tests hang -**Solution**: Ensure all async functions are properly awaited and use `@pytest.mark.asyncio` decorator. - -### Issue: Import errors in tests -**Solution**: Check that all required dependencies are installed in the test environment. - -## Contributing - -When adding new tests: -1. Follow the existing naming conventions (`test__`) -2. Add docstrings explaining what the test validates -3. Use fixtures from `conftest.py` where applicable -4. Mock external dependencies (filesystem, network, subprocesses) -5. Test both success and failure paths -6. Update this README if adding new test files - -## Related Documentation - -- [Isolated Plugin Design](../../../../cpex/framework/isolated/design.md) -- [Plugin Framework Documentation](../../../../cpex/framework/README.md) -- [Main Test Suite](../../../README.md) \ No newline at end of file diff --git a/tests/unit/cpex/framework/isolated/__init__.py b/tests/unit/cpex/framework/isolated/__init__.py deleted file mode 100644 index f34f5b12..00000000 --- a/tests/unit/cpex/framework/isolated/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/isolated/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Unit tests for isolated plugin framework. -""" - -# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/conftest.py b/tests/unit/cpex/framework/isolated/conftest.py deleted file mode 100644 index 9b79a787..00000000 --- a/tests/unit/cpex/framework/isolated/conftest.py +++ /dev/null @@ -1,145 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/isolated/conftest.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Pytest fixtures for isolated plugin tests. -""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from cpex.framework import GlobalContext -from cpex.framework.models import PluginConfig, PluginContext - - -@pytest.fixture -def mock_venv_structure(tmp_path): - """Create a mock virtual environment directory structure. - - Args: - tmp_path: pytest tmp_path fixture - - Returns: - Path to the mock venv directory - """ - venv_path = tmp_path / ".venv" - venv_path.mkdir() - - # Create appropriate bin/Scripts directory based on platform - if sys.platform == "win32": - scripts_dir = venv_path / "Scripts" - scripts_dir.mkdir() - python_exe = scripts_dir / "python.exe" - else: - bin_dir = venv_path / "bin" - bin_dir.mkdir() - python_exe = bin_dir / "python" - - # Create a dummy python executable - python_exe.touch() - python_exe.chmod(0o755) - - return venv_path - - -@pytest.fixture -def sample_plugin_config(tmp_path): - """Create a sample plugin configuration for testing. - - Args: - tmp_path: pytest tmp_path fixture - - Returns: - PluginConfig instance - """ - venv_path = tmp_path / ".venv" - script_path = tmp_path / "plugin" - requirements_file = tmp_path / "requirements.txt" - - config_dict = { - "name": "test_isolated_plugin", - "kind": "isolated_venv", - "description": "Test isolated plugin", - "version": "1.0.0", - "author": "Test Author", - "hooks": ["tool_pre_invoke", "tool_post_invoke"], - "config": { - "venv_path": str(venv_path), - "script_path": str(script_path), - "requirements_file": str(requirements_file), - "class_name": "test_plugin.TestPlugin" - } - } - return PluginConfig(**config_dict) - - -@pytest.fixture -def sample_global_context(): - """Create a sample GlobalContext for testing. - - Returns: - GlobalContext instance - """ - return GlobalContext( - request_id="test-req-123", - user="test_user", - tenant_id="test-tenant", - server_id="test-server" - ) - - -@pytest.fixture -def sample_plugin_context(sample_global_context): - """Create a sample PluginContext for testing. - - Args: - sample_global_context: GlobalContext fixture - - Returns: - PluginContext instance - """ - return PluginContext( - global_context=sample_global_context, - state={"test_key": "test_value"}, - metadata={"test_meta": "test_data"} - ) - - -@pytest.fixture -def mock_communicator(): - """Create a mock VenvProcessCommunicator. - - Returns: - MagicMock instance configured as a communicator - """ - mock_comm = MagicMock() - mock_comm.install_requirements = MagicMock() - mock_comm.send_task = MagicMock(return_value={ - "continue_processing": True, - "modified_payload": None, - "violation": None, - "metadata": {} - }) - return mock_comm - - -@pytest.fixture -def sample_requirements_file(tmp_path): - """Create a sample requirements.txt file. - - Args: - tmp_path: pytest tmp_path fixture - - Returns: - Path to the requirements file - """ - requirements_file = tmp_path / "requirements.txt" - requirements_file.write_text("pytest>=7.0.0\nrequests>=2.28.0\n") - return requirements_file - -# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py deleted file mode 100644 index d60a3089..00000000 --- a/tests/unit/cpex/framework/isolated/test_client.py +++ /dev/null @@ -1,700 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/isolated/test_client.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Unit tests for IsolatedVenvPlugin. -""" - -import json -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from cpex.framework.errors import PluginError -from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult -from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult -from cpex.framework.isolated.client import IsolatedVenvPlugin -from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel - - -class TestIsolatedVenvPlugin: - """Test suite for IsolatedVenvPlugin class.""" - - @pytest.fixture - def mock_config(self, tmp_path): - """Create a mock plugin configuration.""" - # Create the test_plugin directory structure - plugin_dir = tmp_path / "test_plugin" - plugin_dir.mkdir(parents=True, exist_ok=True) - - # Create requirements.txt file - requirements_file = plugin_dir / "requirements.txt" - requirements_file.write_text("pytest>=7.0.0\n") - - venv_path = tmp_path / ".venv" - - config_dict = { - "name": "test_plugin", - "kind": "isolated_venv", - "description": "Test plugin", - "version": "1.0.0", - "author": "Test", - "hooks": ["tool_pre_invoke"], - "config": { - "class_name": "test_plugin.TestPlugin", - "venv_path": venv_path, - "requirements_file": "requirements.txt", # Use relative path - }, - } - - return PluginConfig(**config_dict) - - @pytest.fixture - def plugin(self, mock_config, tmp_path): - """Create an IsolatedVenvPlugin instance.""" - plugin_instance = IsolatedVenvPlugin(mock_config, plugin_dirs=[tmp_path]) - # Override plugin_path to use tmp_path for testing - plugin_instance.plugin_path = tmp_path / "test_plugin" - return plugin_instance - - @pytest.fixture - def plugin_context(self): - """Create a PluginContext instance""" - context = {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}} - plugin_context = PluginContext( - state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") - ) - return plugin_context - - def test_init(self, plugin): - """Test plugin initialization.""" - assert plugin.name == "test_plugin" - assert plugin.implementation == "Python" - assert plugin.comm is None - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.venv.EnvBuilder") - async def test_create_venv_success(self, mock_builder_class, plugin, tmp_path): - """Test successful venv creation.""" - venv_path = tmp_path / ".venv" - mock_builder = MagicMock() - mock_builder_class.return_value = mock_builder - - await plugin.create_venv(str(venv_path)) - - mock_builder_class.assert_called_once() - mock_builder.create.assert_called_once_with(str(venv_path)) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.venv.EnvBuilder") - async def test_create_venv_failure(self, mock_builder_class, plugin, tmp_path): - """Test venv creation failure.""" - venv_path = tmp_path / ".venv" - mock_builder = MagicMock() - mock_builder.create.side_effect = Exception("Creation failed") - mock_builder_class.return_value = mock_builder - - with pytest.raises(Exception, match="Creation failed"): - await plugin.create_venv(str(venv_path)) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - async def test_initialize_success(self, mock_create_venv, mock_comm_class, plugin): - """Test successful plugin initialization.""" - mock_create_venv.return_value = True - mock_comm = MagicMock() - mock_comm_class.return_value = mock_comm - - await plugin.initialize() - - mock_create_venv.assert_called_once() - mock_comm_class.assert_called_once() - mock_comm.install_requirements.assert_called_once() - assert plugin.comm is not None - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_unregistered_hook_type(self, mock_get_registry, plugin, plugin_context): - """Test invoking an unregistered hook type.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = None - mock_get_registry.return_value = mock_registry - - plugin.comm = MagicMock() - - with pytest.raises(PluginError, match="Hook type .* not registered"): - await plugin.invoke_hook("invalid_hook", None, plugin_context) - - @pytest.mark.asyncio - async def test_invoke_hook_no_comm(self, plugin, plugin_context): - """Test invoking hook without initialized communicator.""" - plugin.comm = None - with pytest.raises(PluginError, match="Plugin comm not initialized"): - await plugin.invoke_hook("tool_pre_invoke", None, plugin_context) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_tool_pre_invoke_success(self, mock_get_registry, plugin, plugin_context): - """Test successful tool_pre_invoke hook invocation.""" - # Setup registry - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = ToolPreInvokeResult - mock_get_registry.return_value = mock_registry - response_data = { - "continue_processing": True, - "modified_payload": {"name": "test_tool", "args": {}}, - "violation": None, - "metadata": {}, - } - - mock_registry.json_to_result = MagicMock() - mock_registry.json_to_result.return_value = ToolPreInvokeResult( - continue_processing=response_data.get("continue_processing"), - modified_payload=response_data.get("modified_payload"), - violation=response_data.get("violation"), - metadata=response_data.get("metadata"), - ) - # Setup communicator - mock_comm = MagicMock() - mock_comm.send_task.return_value = response_data - plugin.comm = mock_comm - - # Create payload and context - from cpex.framework.hooks.tools import ToolPreInvokePayload - - payload = ToolPreInvokePayload(name="test_tool", args={}) - result = await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) - - assert isinstance(result, ToolPreInvokeResult) - assert result.continue_processing is True - mock_comm.send_task.assert_called_once() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_tool_post_invoke_success(self, mock_get_registry, plugin, plugin_context): - """Test successful tool_post_invoke hook invocation.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = ToolPostInvokeResult - mock_get_registry.return_value = mock_registry - - mock_comm = MagicMock() - response_data = { - "continue_processing": True, - "modified_payload": {"name": "test_tool", "result": "success"}, - "violation": None, - "metadata": {}, - } - mock_comm.send_task.return_value = response_data - mock_registry.json_to_result = MagicMock() - mock_registry.json_to_result.return_value = ToolPostInvokeResult( - continue_processing=response_data.get("continue_processing"), - modified_payload=response_data.get("modified_payload"), - violation=response_data.get("violation"), - metadata=response_data.get("metadata"), - ) - plugin.comm = mock_comm - - from cpex.framework.hooks.tools import ToolPostInvokePayload - - payload = ToolPostInvokePayload(name="test_tool", result="success") - - result = await plugin.invoke_hook("tool_post_invoke", payload, plugin_context) - - assert isinstance(result, ToolPostInvokeResult) - assert result.continue_processing is True - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_prompt_pre_fetch_success(self, mock_get_registry, plugin, plugin_context): - """Test successful prompt_pre_fetch hook invocation.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = PromptPrehookResult - mock_registry.json_to_result = MagicMock() - mock_get_registry.return_value = mock_registry - - mock_comm = MagicMock() - response_data = { - "continue_processing": True, - "modified_payload": {"prompt_id": "test", "args": {}}, - "violation": None, - "metadata": {}, - } - mock_comm.send_task.return_value = response_data - mock_registry.json_to_result.return_value = PromptPrehookResult( - continue_processing=response_data.get("continue_processing"), - modified_payload=response_data.get("modified_payload"), - violation=response_data.get("violation"), - metadata=response_data.get("metadata"), - ) - plugin.comm = mock_comm - - from cpex.framework.hooks.prompts import PromptPrehookPayload - - payload = PromptPrehookPayload(prompt_id="test", args={}) - - result = await plugin.invoke_hook("prompt_pre_fetch", payload, plugin_context) - - assert isinstance(result, PromptPrehookResult) - assert result.continue_processing is True - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_prompt_post_fetch_success(self, mock_get_registry, plugin, plugin_context): - """Test successful prompt_post_fetch hook invocation.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = PromptPosthookResult - mock_get_registry.return_value = mock_registry - - mock_comm = MagicMock() - response_data = { - "continue_processing": True, - "modified_payload": {"prompt_id": "test", "result": {}}, - "violation": None, - "metadata": {}, - } - mock_registry.json_to_result = MagicMock() - mock_registry.json_to_result.return_value = PromptPosthookResult( - continue_processing=response_data.get("continue_processing"), - modified_payload=response_data.get("modified_payload"), - violation=response_data.get("violation"), - metadata=response_data.get("metadata"), - ) - mock_comm.send_task.return_value = response_data - plugin.comm = mock_comm - - from cpex.framework.hooks.prompts import PromptPosthookPayload - - payload = PromptPosthookPayload(prompt_id="test", result={}) - result = await plugin.invoke_hook("prompt_post_fetch", payload, plugin_context) - - assert isinstance(result, PromptPosthookResult) - assert result.continue_processing is True - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_with_violation(self, mock_get_registry, plugin, plugin_context): - """Test hook invocation that returns a violation.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = ToolPreInvokeResult - mock_get_registry.return_value = mock_registry - mock_registry.json_to_result = MagicMock() - - mock_comm = MagicMock() - response_data = { - "continue_processing": False, - "modified_payload": None, - "violation": {"reason": "Policy violation", "description": "severity high", "code": "PROHIBITED_CONTENT"}, - "metadata": {}, - } - mock_comm.send_task.return_value = response_data - plugin.comm = mock_comm - mock_registry.json_to_result.return_value = ToolPreInvokeResult( - continue_processing=response_data.get("continue_processing"), - modified_payload=response_data.get("modified_payload"), - violation=response_data.get("violation"), - metadata=response_data.get("metadata"), - ) - - from cpex.framework.hooks.tools import ToolPreInvokePayload - - payload = ToolPreInvokePayload(name="test_tool", args={}) - - result = await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) - - assert isinstance(result, ToolPreInvokeResult) - assert result.continue_processing is False - assert result.violation is not None - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_plugin_error(self, mock_get_registry, plugin, plugin_context): - """Test hook invocation that raises PluginError.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = ToolPreInvokeResult - mock_get_registry.return_value = mock_registry - - mock_comm = MagicMock() - mock_comm.send_task.side_effect = PluginError( - error=PluginErrorModel(message="Test error", plugin_name="test_plugin") - ) - plugin.comm = mock_comm - - from cpex.framework.hooks.tools import ToolPreInvokePayload - - payload = ToolPreInvokePayload(name="test_tool", args={}) - with pytest.raises(PluginError): - await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - @patch("cpex.framework.isolated.client.convert_exception_to_error") - async def test_invoke_hook_generic_exception(self, mock_convert, mock_get_registry, plugin, plugin_context): - """Test hook invocation that raises generic exception.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = ToolPreInvokeResult - mock_get_registry.return_value = mock_registry - - mock_comm = MagicMock() - mock_comm.send_task.side_effect = ValueError("Test error") - plugin.comm = mock_comm - - mock_convert.return_value = PluginErrorModel(message="Converted error", plugin_name="test_plugin") - - from cpex.framework.hooks.tools import ToolPreInvokePayload - - payload = ToolPreInvokePayload(name="test_tool", args={}) - - with pytest.raises(PluginError): - await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) - - mock_convert.assert_called_once() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.get_hook_registry") - async def test_invoke_hook_serialization(self, mock_get_registry, plugin): - """Test that payload and context are properly serialized.""" - mock_registry = MagicMock() - mock_registry.get_result_type.return_value = ToolPreInvokeResult - mock_get_registry.return_value = mock_registry - - mock_comm = MagicMock() - response_data = {"continue_processing": True, "modified_payload": None, "violation": None, "metadata": {}} - mock_comm.send_task.return_value = response_data - plugin.comm = mock_comm - - from cpex.framework import GlobalContext - from cpex.framework.hooks.tools import ToolPreInvokePayload - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - global_ctx = GlobalContext(request_id="req-123", user="alice") - context = PluginContext(global_context=global_ctx) - - await plugin.invoke_hook("tool_pre_invoke", payload, context) - - # Verify send_task was called with serialized data - call_args = mock_comm.send_task.call_args - task_data = call_args[1]["task_data"] - - assert "payload" in task_data - assert "context" in task_data - assert task_data["hook_type"] == "tool_pre_invoke" - assert task_data["plugin_name"] == plugin.name - - def test_get_safe_config(self, plugin): - """Test that get_safe_config returns sanitized config.""" - safe_config = plugin.config.get_safe_config() - assert isinstance(safe_config, str) - # Should be valid JSON - import json - - config_dict = json.loads(safe_config) - assert "name" in config_dict - - def test_cache_dir_creation(self, plugin): - """Test that cache directory is created on plugin initialization.""" - assert plugin.cache_dir.exists() - assert plugin.cache_dir.is_dir() - assert plugin.cache_dir.name == "venv_cache" - - def test_compute_requirements_hash_with_file(self, plugin, tmp_path): - """Test computing hash of existing requirements file.""" - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\nrequests==2.28.0\n") - - hash1 = plugin._compute_requirements_hash(str(req_file)) - assert isinstance(hash1, str) - assert len(hash1) == 64 # SHA256 produces 64 hex characters - - # Same content should produce same hash - hash2 = plugin._compute_requirements_hash(str(req_file)) - assert hash1 == hash2 - - def test_compute_requirements_hash_different_content(self, plugin, tmp_path): - """Test that different content produces different hashes.""" - req_file1 = tmp_path / "requirements1.txt" - req_file1.write_text("pytest==7.0.0\n") - - req_file2 = tmp_path / "requirements2.txt" - req_file2.write_text("pytest==8.0.0\n") - - hash1 = plugin._compute_requirements_hash(str(req_file1)) - hash2 = plugin._compute_requirements_hash(str(req_file2)) - - assert hash1 != hash2 - - def test_compute_requirements_hash_nonexistent_file(self, plugin, tmp_path): - """Test computing hash of non-existent file.""" - nonexistent = tmp_path / "nonexistent.txt" - hash_result = plugin._compute_requirements_hash(str(nonexistent)) - - # Should return hash of empty content - assert isinstance(hash_result, str) - assert len(hash_result) == 64 - - def test_get_cache_metadata_path(self, plugin, tmp_path): - """Test getting cache metadata path.""" - venv_path = tmp_path / ".venv" - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - - assert metadata_path.parent == plugin.cache_dir - assert metadata_path.name == ".venv_metadata.json" - assert isinstance(metadata_path, Path) - - def test_is_venv_cache_valid_no_venv(self, plugin, tmp_path): - """Test cache validation when venv doesn't exist.""" - venv_path = tmp_path / ".venv" - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) - assert result is False - - def test_is_venv_cache_valid_no_metadata(self, plugin, tmp_path): - """Test cache validation when metadata file doesn't exist.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) - assert result is False - - def test_is_venv_cache_valid_hash_mismatch(self, plugin, tmp_path): - """Test cache validation when requirements hash doesn't match.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - # Create metadata with different hash - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - metadata = { - "venv_path": str(venv_path), - "requirements_file": str(req_file), - "requirements_hash": "different_hash", - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - } - metadata_path.write_text(json.dumps(metadata)) - - result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) - assert result is False - - def test_is_venv_cache_valid_success(self, plugin, tmp_path): - """Test successful cache validation.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - # Create metadata with correct hash - req_hash = plugin._compute_requirements_hash(str(req_file)) - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - metadata = { - "venv_path": str(venv_path), - "requirements_file": str(req_file), - "requirements_hash": req_hash, - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - } - metadata_path.write_text(json.dumps(metadata)) - - result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) - assert result is True - - def test_is_venv_cache_valid_invalid_json(self, plugin, tmp_path): - """Test cache validation with invalid JSON metadata.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - # Create invalid JSON metadata - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - metadata_path.write_text("invalid json {") - - result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) - assert result is False - - def test_save_cache_metadata(self, plugin, tmp_path): - """Test saving cache metadata.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - plugin._save_cache_metadata(str(venv_path), str(req_file)) - - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - assert metadata_path.exists() - - with open(metadata_path) as f: - metadata = json.load(f) - - assert "venv_path" in metadata - assert "requirements_file" in metadata - assert "requirements_hash" in metadata - assert "python_version" in metadata - assert metadata["requirements_hash"] == plugin._compute_requirements_hash(str(req_file)) - - def test_save_cache_metadata_nonexistent_requirements(self, plugin, tmp_path): - """Test saving cache metadata with non-existent requirements file.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "nonexistent.txt" - - plugin._save_cache_metadata(str(venv_path), str(req_file)) - - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - assert metadata_path.exists() - - with open(metadata_path) as f: - metadata = json.load(f) - - assert metadata["requirements_file"] is None - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.venv.EnvBuilder") - @patch("cpex.framework.isolated.client.shutil.rmtree") - async def test_create_venv_with_cache_valid(self, mock_rmtree, mock_builder_class, plugin, tmp_path): - """Test create_venv uses cache when valid.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - # Setup valid cache - plugin._save_cache_metadata(str(venv_path), str(req_file)) - - await plugin.create_venv(str(venv_path), str(req_file), use_cache=True) - - # Should not create new venv or remove existing - mock_builder_class.assert_not_called() - mock_rmtree.assert_not_called() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.venv.EnvBuilder") - @patch("cpex.framework.isolated.client.shutil.rmtree") - async def test_create_venv_with_cache_invalid(self, mock_rmtree, mock_builder_class, plugin, tmp_path): - """Test create_venv recreates when cache invalid.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - # Setup invalid cache (wrong hash) - metadata_path = plugin._get_cache_metadata_path(str(venv_path)) - metadata = { - "venv_path": str(venv_path), - "requirements_file": str(req_file), - "requirements_hash": "wrong_hash", - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - } - metadata_path.write_text(json.dumps(metadata)) - - mock_builder = MagicMock() - mock_builder_class.return_value = mock_builder - - await plugin.create_venv(str(venv_path), str(req_file), use_cache=True) - - # Should remove old venv and create new one - mock_rmtree.assert_called_once_with(venv_path) - mock_builder_class.assert_called_once() - mock_builder.create.assert_called_once() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.venv.EnvBuilder") - async def test_create_venv_without_cache(self, mock_builder_class, plugin, tmp_path): - """Test create_venv without using cache.""" - venv_path = tmp_path / ".venv" - req_file = tmp_path / "requirements.txt" - req_file.write_text("pytest==7.0.0\n") - - mock_builder = MagicMock() - mock_builder_class.return_value = mock_builder - - await plugin.create_venv(str(venv_path), str(req_file), use_cache=False) - - # Should create new venv - mock_builder_class.assert_called_once() - mock_builder.create.assert_called_once() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - @patch.object(IsolatedVenvPlugin, "_is_venv_cache_valid") - async def test_initialize_with_valid_cache(self, mock_cache_valid, mock_create_venv, mock_comm_class, plugin): - """Test initialize with valid cache skips requirements installation.""" - mock_cache_valid.return_value = True - mock_create_venv.return_value = None - mock_comm = MagicMock() - mock_comm_class.return_value = mock_comm - - await plugin.initialize() - - # Should not install requirements when cache is valid - mock_comm.install_requirements.assert_not_called() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - @patch.object(IsolatedVenvPlugin, "_is_venv_cache_valid") - @patch.object(IsolatedVenvPlugin, "_save_cache_metadata") - async def test_initialize_with_invalid_cache( - self, mock_save_metadata, mock_cache_valid, mock_create_venv, mock_comm_class, plugin - ): - """Test initialize with invalid cache installs requirements.""" - mock_cache_valid.return_value = False - mock_create_venv.return_value = True - mock_comm = MagicMock() - mock_comm_class.return_value = mock_comm - - await plugin.initialize() - - # Should install requirements when cache is invalid - mock_comm.install_requirements.assert_called_once() - mock_save_metadata.assert_called_once() - @pytest.mark.asyncio - async def test_cleanup(self, plugin): - """Test cleanup method stops worker process.""" - mock_comm = MagicMock() - plugin.comm = mock_comm - - await plugin.cleanup() - - mock_comm.stop_worker.assert_called_once() - assert plugin.comm is None - - @pytest.mark.asyncio - async def test_cleanup_no_comm(self, plugin): - """Test cleanup when comm is None.""" - plugin.comm = None - - # Should not raise error - await plugin.cleanup() - - - @pytest.mark.asyncio - async def test_cleanup(self, plugin): - """Test cleanup method stops worker process.""" - mock_comm = MagicMock() - plugin.comm = mock_comm - - await plugin.cleanup() - - mock_comm.stop_worker.assert_called_once() - assert plugin.comm is None - - @pytest.mark.asyncio - async def test_cleanup_no_comm(self, plugin): - """Test cleanup when comm is None.""" - plugin.comm = None - - # Should not raise error - await plugin.cleanup() - - -# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py deleted file mode 100644 index a7501a65..00000000 --- a/tests/unit/cpex/framework/isolated/test_integration.py +++ /dev/null @@ -1,392 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/isolated/test_integration.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Integration tests for isolated plugin system. -""" - -from unittest.mock import MagicMock, patch - -import pytest -import yaml - -from cpex.framework import GlobalContext, PluginManager -from cpex.framework.hooks.tools import ToolPreInvokePayload -from cpex.framework.isolated.client import IsolatedVenvPlugin -from cpex.framework.models import Config, PluginConfig - - -class TestIsolatedPluginIntegration: - """Integration tests for the isolated plugin system.""" - - @pytest.fixture - def integration_config_path(self, tmp_path): - """Create a temporary config file for integration testing.""" - - cfg = Config( - plugins=[ - PluginConfig( - name="test_isolated_plugin", - kind="isolated_venv", - description="Test isolated plugin", - version="1.0.0", - author="Test", - hooks=["tool_pre_invoke"], - config={"class_name": "test_plugin.TestPlugin", "requirements_file": "requirements.txt"}, - ) - ], - plugin_dirs=[str((tmp_path / "xplugins").resolve())], - plugin_settings={ - "parallel_execution_within_band": True, - "plugin_timeout": 30, - "fail_on_plugin_error": False, - }, - ) - config_file = tmp_path / "xplugins" / "test_config.yaml" - class_root = tmp_path / "xplugins" / "test_plugin" - class_root.mkdir(parents=True, exist_ok=True) - dumped_cfg = cfg.model_dump(mode="json") - config_content = yaml.safe_dump(dumped_cfg, default_flow_style=False) - config_file.write_text(config_content) - return str(config_file) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - async def test_plugin_manager_with_isolated_plugin( - self, mock_create_venv, mock_comm_class, integration_config_path, tmp_path - ): - """Test PluginManager loading and initializing an isolated plugin.""" - # Setup mocks - mock_create_venv.return_value = None - mock_comm = MagicMock() - mock_comm.install_requirements = MagicMock() - mock_comm_class.return_value = mock_comm - with patch("cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS", {str((tmp_path / "xplugins").resolve())}): - # Create manager - manager = PluginManager(integration_config_path) - - await manager.initialize() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_class, tmp_path): - """Test full lifecycle of an isolated plugin.""" - # Setup - mock_create_venv.return_value = None - mock_comm = MagicMock() - mock_comm.install_requirements = MagicMock() - mock_comm.send_task.return_value = { - "continue_processing": True, - "modified_payload": None, - "violation": None, - "metadata": {}, - } - mock_comm_class.return_value = mock_comm - - config_dict = { - "name": "test_plugin", - "kind": "isolated_venv", - "description": "Test plugin", - "version": "1.0.0", - "author": "Test", - "hooks": ["tool_pre_invoke"], - "config": { - "class_name": "test_plugin.TestPlugin", - "requirements_file": "requirements.txt", - }, - } - resolved_plugin_path = (tmp_path / "xplugins").resolve() - plugin_root = resolved_plugin_path / "test_plugin" - plugin_root.mkdir(parents=True, exist_ok=True) - # resolved_plugin_path.mkdir(parents=True, exist_ok=True) - with patch("cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS", {str(resolved_plugin_path)}): - config = PluginConfig(**config_dict) - - # Create and initialize plugin - plugin = IsolatedVenvPlugin(config, plugin_dirs=[resolved_plugin_path]) - - with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: - from cpex.framework.hooks.tools import ToolPreInvokeResult - - mock_reg = MagicMock() - mock_reg.get_result_type.return_value = ToolPreInvokeResult - mock_reg.json_to_result = MagicMock() - mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True) - mock_registry.return_value = mock_reg - - await plugin.initialize() - - # Invoke hook - payload = ToolPreInvokePayload(name="test_tool", args={}) - global_ctx = GlobalContext(request_id="req-123") - from cpex.framework.models import PluginContext - - context = PluginContext(global_context=global_ctx) - - result = await plugin.invoke_hook("tool_pre_invoke", payload, context) - - assert result is not None - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_isolated_plugin_error_handling(self, tmp_path): - """Test error handling in isolated plugin.""" - config_dict = { - "name": "test_plugin", - "kind": "isolated_venv", - "description": "Test plugin", - "version": "1.0.0", - "author": "Test", - "hooks": ["tool_pre_invoke"], - "config": { - "class_name": "test_plugin.TestPlugin", - "requirements_file": "requirements.txt", - }, - } - config = PluginConfig(**config_dict) - resolved_plugin_path = (tmp_path / "xplugins").resolve() - cache_root = resolved_plugin_path / "test_plugin" - cache_root.mkdir(parents=True, exist_ok=True) - # resolved_plugin_path.mkdir(parents=True, exist_ok=True) - - plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) - - # Try to invoke hook without initialization - from cpex.framework.errors import PluginError - - payload = ToolPreInvokePayload(name="test_tool", args={}) - global_ctx = GlobalContext(request_id="req-123") - from cpex.framework.models import PluginContext - - context = PluginContext(global_context=global_ctx) - - with pytest.raises(PluginError, match="Plugin comm not initialized"): - await plugin.invoke_hook("tool_pre_invoke", payload, context) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - async def test_isolated_plugin_with_multiple_hooks(self, mock_create_venv, mock_comm_class, tmp_path): - """Test isolated plugin with multiple hook types.""" - mock_create_venv.return_value = None - mock_comm = MagicMock() - mock_comm.install_requirements = MagicMock() - mock_comm_class.return_value = mock_comm - - config_dict = { - "name": "test_plugin", - "kind": "isolated_venv", - "description": "Test plugin", - "version": "1.0.0", - "author": "Test", - "hooks": ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"], - "config": { - "class_name": "test_plugin.TestPlugin", - "requirements_file": "requirements.txt", - "script_path": "tests/unit/cpex/fixtures/plugins/isolated", - }, - } - - config = PluginConfig(**config_dict) - resolved_plugin_path = (tmp_path / "xplugins").resolve() - cache_root = resolved_plugin_path / "test_plugin" - cache_root.mkdir(parents=True, exist_ok=True) - - plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) - - await plugin.initialize() - - # Test each hook type - hook_types = [ - ("tool_pre_invoke", "ToolPreInvokeResult"), - ("tool_post_invoke", "ToolPostInvokeResult"), - ("prompt_pre_fetch", "PromptPrehookResult"), - ("prompt_post_fetch", "PromptPosthookResult"), - ] - - for hook_type, result_type_name in hook_types: - mock_comm.send_task.return_value = { - "continue_processing": True, - "modified_payload": None, - "violation": None, - "metadata": {}, - } - - with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: - # Import the appropriate result type - if "Tool" in result_type_name: - from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult - - result_class = ToolPreInvokeResult if "Pre" in result_type_name else ToolPostInvokeResult - else: - from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult - - result_class = PromptPrehookResult if "Pre" in result_type_name else PromptPosthookResult - - mock_reg = MagicMock() - mock_reg.get_result_type.return_value = result_class - mock_registry.return_value = mock_reg - - # Create appropriate payload - if "tool" in hook_type: - from cpex.framework.hooks.tools import ToolPostInvokePayload, ToolPreInvokePayload - - payload = ( - ToolPreInvokePayload(name="test", args={}) - if "pre" in hook_type - else ToolPostInvokePayload(name="test", result={}) - ) - else: - from cpex.framework.hooks.prompts import PromptPosthookPayload, PromptPrehookPayload - - payload = ( - PromptPrehookPayload(prompt_id="test", args={}) - if "pre" in hook_type - else PromptPosthookPayload(prompt_id="test", result={}) - ) - - global_ctx = GlobalContext(request_id="req-123") - from cpex.framework.models import PluginContext - - context = PluginContext(global_context=global_ctx) - - result = await plugin.invoke_hook(hook_type, payload, context) - assert result is not None - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - async def test_isolated_plugin_context_propagation(self, mock_create_venv, mock_comm_class, tmp_path): - """Test that context is properly propagated through isolated plugin.""" - mock_create_venv.return_value = None - mock_comm = MagicMock() - mock_comm.install_requirements = MagicMock() - - # Capture the task data sent - captured_task = None - - def capture_task(script_path, task_data, max_content_size): - nonlocal captured_task - captured_task = task_data - return {"continue_processing": True, "modified_payload": None, "violation": None, "metadata": {}} - - mock_comm.send_task = capture_task - mock_comm_class.return_value = mock_comm - - config_dict = { - "name": "test_plugin", - "kind": "isolated_venv", - "description": "Test plugin", - "version": "1.0.0", - "author": "Test", - "hooks": ["tool_pre_invoke"], - "config": { - "class_name": "test_plugin.TestPlugin", - "requirements_file": "requirements.txt", - }, - } - config = PluginConfig(**config_dict) - resolved_plugin_path = (tmp_path / "xplugins").resolve() - cache_root = resolved_plugin_path / "test_plugin" - cache_root.mkdir(parents=True, exist_ok=True) - - plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) - - await plugin.initialize() - - with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: - from cpex.framework.hooks.tools import ToolPreInvokeResult - - mock_reg = MagicMock() - mock_reg.get_result_type.return_value = ToolPreInvokeResult - mock_registry.return_value = mock_reg - - # Create context with metadata - global_ctx = GlobalContext(request_id="req-123", user="alice", tenant_id="tenant-1") - from cpex.framework.models import PluginContext - - context = PluginContext(global_context=global_ctx, state={"key": "value"}, metadata={"custom": "data"}) - - payload = ToolPreInvokePayload(name="test_tool", args={"arg1": "value1"}) - - await plugin.invoke_hook("tool_pre_invoke", payload, context) - - # Verify context was properly serialized and sent - assert captured_task is not None - assert "context" in captured_task - assert captured_task["context"]["global_context"]["request_id"] == "req-123" - assert captured_task["context"]["global_context"]["user"] == "alice" - assert captured_task["context"]["state"]["key"] == "value" - assert captured_task["context"]["metadata"]["custom"] == "data" - - # Verify payload was serialized - assert "payload" in captured_task - assert captured_task["payload"]["name"] == "test_tool" - assert captured_task["payload"]["args"]["arg1"] == "value1" - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.client.VenvProcessCommunicator") - @patch.object(IsolatedVenvPlugin, "create_venv") - async def test_isolated_plugin_violation_handling(self, mock_create_venv, mock_comm_class, tmp_path): - """Test handling of policy violations in isolated plugin.""" - mock_create_venv.return_value = None - mock_comm = MagicMock() - mock_comm.install_requirements = MagicMock() - mock_comm.send_task.return_value = { - "continue_processing": False, - "modified_payload": None, - "violation": {"reason": "Policy violation", "description": "severity high", "code": "PROHIBITED_CONTENT"}, - "metadata": {}, - } - mock_comm_class.return_value = mock_comm - - config_dict = { - "name": "test_plugin", - "kind": "isolated_venv", - "description": "Test plugin", - "version": "1.0.0", - "author": "Test", - "hooks": ["tool_pre_invoke"], - "config": { - "class_name": "test_plugin.TestPlugin", - "requirements_file": "requirements.txt", - }, - } - config = PluginConfig(**config_dict) - resolved_plugin_path = (tmp_path / "xplugins").resolve() - cache_root = resolved_plugin_path / "test_plugin" - cache_root.mkdir(parents=True, exist_ok=True) - - plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) - - await plugin.initialize() - - with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: - from cpex.framework.hooks.tools import ToolPreInvokeResult - - mock_reg = MagicMock() - mock_reg.get_result_type.return_value = ToolPreInvokeResult - mock_reg.json_to_result = MagicMock() - mock_reg.json_to_result.return_value = ToolPreInvokeResult( - continue_processing=False, - violation={"reason": "Policy violation", "description": "severity high", "code": "PROHIBITED_CONTENT"}, - ) - mock_registry.return_value = mock_reg - - payload = ToolPreInvokePayload(name="dangerous_tool", args={}) - global_ctx = GlobalContext(request_id="req-123") - from cpex.framework.models import PluginContext - - context = PluginContext(global_context=global_ctx) - - result = await plugin.invoke_hook("tool_pre_invoke", payload, context) - - assert result.continue_processing is False - assert result.violation is not None - - -# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py deleted file mode 100644 index 82b2bab8..00000000 --- a/tests/unit/cpex/framework/isolated/test_venv_comm.py +++ /dev/null @@ -1,1376 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/isolated/test_venv_comm.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Unit tests for VenvProcessCommunicator. -""" - -import subprocess -import sys -from pathlib import Path -from queue import Queue -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cpex.framework.isolated.venv_comm import VenvProcessCommunicator - - -class TestVenvProcessCommunicator: - """Test suite for VenvProcessCommunicator class.""" - - @pytest.fixture - def mock_venv_path(self, tmp_path): - """Create a mock venv directory structure.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - - # Create appropriate bin/Scripts directory based on platform - if sys.platform == "win32": - scripts_dir = venv_path / "Scripts" - scripts_dir.mkdir() - python_exe = scripts_dir / "python.exe" - else: - bin_dir = venv_path / "bin" - bin_dir.mkdir() - python_exe = bin_dir / "python" - - # Create a dummy python executable - python_exe.touch() - python_exe.chmod(0o755) - - return venv_path - - @pytest.fixture - def communicator(self, mock_venv_path): - """Create a VenvProcessCommunicator instance with mock venv.""" - return VenvProcessCommunicator(str(mock_venv_path)) - - def test_init_valid_venv(self, mock_venv_path): - """Test initialization with valid venv path.""" - comm = VenvProcessCommunicator(str(mock_venv_path)) - assert comm.venv_path == mock_venv_path - assert comm.python_executable is not None - assert Path(comm.python_executable).exists() - - def test_init_invalid_venv(self, tmp_path): - """Test initialization with invalid venv path raises error.""" - invalid_path = tmp_path / "nonexistent" - with pytest.raises(FileNotFoundError, match="Python executable not found"): - VenvProcessCommunicator(str(invalid_path)) - - def test_get_python_executable_unix(self, tmp_path): - """Test getting Python executable path on Unix-like systems.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - bin_dir = venv_path / "bin" - bin_dir.mkdir() - python_exe = bin_dir / "python" - python_exe.touch() - - with patch("sys.platform", "linux"): - comm = VenvProcessCommunicator(str(venv_path)) - assert comm.python_executable == str(python_exe) - - def test_get_python_executable_windows(self, tmp_path): - """Test getting Python executable path on Windows.""" - venv_path = tmp_path / ".venv" - venv_path.mkdir() - scripts_dir = venv_path / "Scripts" - scripts_dir.mkdir() - python_exe = scripts_dir / "python.exe" - python_exe.touch() - - with patch("sys.platform", "win32"): - comm = VenvProcessCommunicator(str(venv_path)) - assert comm.python_executable == str(python_exe) - - @patch("subprocess.check_call") - def test_install_requirements_success(self, mock_check_call, communicator, tmp_path): - """Test successful requirements installation.""" - requirements_file = tmp_path / "requirements.txt" - requirements_file.write_text("pytest>=7.0.0\n") - - mock_check_call.return_value = 0 - - communicator.install_requirements(str(requirements_file)) - - mock_check_call.assert_called_with([ - communicator.python_executable, - "-m", - "pip", - "install", - "-r", - str(requirements_file) - ]) - - @patch("subprocess.check_call") - def test_install_requirements_failure(self, mock_check_call, communicator, tmp_path): - """Test requirements installation failure.""" - requirements_file = tmp_path / "requirements.txt" - requirements_file.write_text("invalid-package-name-xyz\n") - - # Simulate subprocess.check_call raising an exception - mock_check_call.side_effect = subprocess.CalledProcessError(1, "pip install") - - with pytest.raises(RuntimeError, match=f"Failed to install requirements from {requirements_file}"): - communicator.install_requirements(str(requirements_file)) - - def test_install_requirements_nonexistent_file(self, communicator): - """Test install_requirements with nonexistent file does nothing.""" - # Should not raise an error if file doesn't exist - communicator.install_requirements("nonexistent_requirements.txt") - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("cpex.framework.isolated.venv_comm.Queue") - def test_send_task_success(self, mock_queue_class, mock_thread, mock_popen, communicator): - """Test successful task sending and response.""" - task_data = {"task_type": "info", "data": "test"} - - # Mock the process - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - # Mock the thread - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Mock the Queue to return our response - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = {"status": "success", "result": "ok", "request_id": "test-id"} - mock_queue_class.return_value = mock_queue_instance - - # Manually start the worker to set up the infrastructure - communicator.start_worker("test_script.py") - - result = communicator.send_task("test_script.py", task_data) - - # Request ID should be removed from response - assert result == {"status": "success", "result": "ok"} - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("cpex.framework.isolated.venv_comm.Queue") - def test_send_task_process_failure(self, mock_queue_class, mock_thread, mock_popen, communicator): - """Test task sending with process failure.""" - task_data = {"task_type": "test"} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Mock the Queue to return error response - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = {"status": "error", "message": "Process failed", "request_id": "test-id"} - mock_queue_class.return_value = mock_queue_instance - - # Start worker - communicator.start_worker("test_script.py") - - with pytest.raises(RuntimeError, match="Worker process error: Process failed"): - communicator.send_task("test_script.py", task_data) - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_timeout(self, mock_thread, mock_popen, communicator): - """Test task sending with timeout.""" - task_data = {"task_type": "test"} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Don't put anything in the queue to simulate timeout - - with pytest.raises(RuntimeError, match="Worker process timed out"): - communicator.send_task("test_script.py", task_data, timeout=0.1) - - @patch("subprocess.Popen") - def test_send_task_communication_error(self, mock_popen, communicator): - """Test task sending with communication error.""" - task_data = {"task_type": "test"} - - mock_popen.side_effect = OSError("Connection failed") - - with pytest.raises(RuntimeError, match="Failed to start worker process"): - communicator.send_task("test_script.py", task_data) - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("cpex.framework.isolated.venv_comm.Queue") - def test_send_task_with_complex_data(self, mock_queue_class, mock_thread, mock_popen, communicator): - """Test sending task with complex nested data structures.""" - task_data = { - "task_type": "load_and_run_hook", - "config": {"nested": {"data": [1, 2, 3]}}, - "payload": {"args": {"key": "value"}}, - "context": {"state": {}, "metadata": {}}, - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Mock the Queue to return response - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = { - "status": "success", - "result": {"data": "processed"}, - "request_id": "test-id", - } - mock_queue_class.return_value = mock_queue_instance - - # Start worker - communicator.start_worker("worker.py") - - result = communicator.send_task("worker.py", task_data) - - assert result == {"status": "success", "result": {"data": "processed"}} - # Verify the task was serialized properly - call_args = mock_popen.call_args - assert call_args is not None - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("cpex.framework.isolated.venv_comm.Queue") - @patch("os.getcwd") - def test_send_task_maintains_cwd(self, mock_getcwd, mock_queue_class, mock_thread, mock_popen, communicator): - """Test that send_task maintains current working directory.""" - mock_getcwd.return_value = "/test/path" - task_data = {"task_type": "test"} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Mock the Queue to return response - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = {"status": "ok", "request_id": "test-id"} - mock_queue_class.return_value = mock_queue_instance - - # Start worker - communicator.start_worker("test_script.py") - - communicator.send_task("test_script.py", task_data) - - # Verify cwd was passed to Popen - call_kwargs = mock_popen.call_args[1] - assert call_kwargs["cwd"] == "/test/path" - - def test_python_executable_property(self, communicator): - """Test that python_executable property is accessible.""" - assert communicator.python_executable is not None - assert isinstance(communicator.python_executable, str) - assert Path(communicator.python_executable).exists() - - def test_venv_path_property(self, communicator, mock_venv_path): - """Test that venv_path property is accessible.""" - assert communicator.venv_path == mock_venv_path - assert isinstance(communicator.venv_path, Path) - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_start_worker_success(self, mock_thread, mock_popen, communicator): - """Test successful worker process start.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.pid = 12345 - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - assert communicator.running is True - assert communicator.process is not None - mock_popen.assert_called_once() - # Should start two threads (stdout and stderr readers) - assert mock_thread.call_count == 2 - - @patch("subprocess.Popen") - def test_start_worker_already_running(self, mock_popen, communicator): - """Test starting worker when already running.""" - communicator.running = True - communicator.process = MagicMock() - - communicator.start_worker("test_script.py") - - # Should not create new process - mock_popen.assert_not_called() - - @patch("subprocess.Popen") - def test_start_worker_failure(self, mock_popen, communicator): - """Test worker start failure.""" - mock_popen.side_effect = OSError("Failed to start") - - with pytest.raises(RuntimeError, match="Failed to start worker process"): - communicator.start_worker("test_script.py") - - assert communicator.running is False - - def test_stop_worker_not_running(self, communicator): - """Test stopping worker when not running.""" - communicator.running = False - communicator.process = None - - # Should not raise error - communicator.stop_worker() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_stop_worker_success(self, mock_thread, mock_popen, communicator): - """Test successful worker stop.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.wait.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread_instance.is_alive.return_value = False - mock_thread.return_value = mock_thread_instance - - # Start worker first - communicator.start_worker("test_script.py") - - # Stop worker - communicator.stop_worker() - - assert communicator.running is False - assert communicator.process is None - mock_process.wait.assert_called() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_stop_worker_timeout(self, mock_thread, mock_popen, communicator): - """Test worker stop with timeout.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.wait.side_effect = subprocess.TimeoutExpired("cmd", 5) - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread_instance.is_alive.return_value = False - mock_thread.return_value = mock_thread_instance - - # Start worker first - communicator.start_worker("test_script.py") - - # Stop worker - communicator.stop_worker() - - # Should kill process after timeout - mock_process.kill.assert_called_once() - - def test_is_alive_not_running(self, communicator): - """Test is_alive when worker not running.""" - communicator.running = False - communicator.process = None - - assert communicator.is_alive() is False - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_is_alive_running(self, mock_thread, mock_popen, communicator): - """Test is_alive when worker is running.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - assert communicator.is_alive() is True - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_is_alive_process_terminated(self, mock_thread, mock_popen, communicator): - """Test is_alive when process has terminated.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = 1 # Process terminated - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - assert communicator.is_alive() is False - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_stderr_with_output(self, mock_thread, mock_popen, communicator): - """Test _read_stderr method reads and logs stderr output.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - - # Mock stderr with some output - mock_stderr = MagicMock() - mock_stderr.readline.side_effect = [ - "Error line 1\n", - "Error line 2\n", - "", # Empty string signals end - ] - mock_process.stderr = mock_stderr - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Start worker to trigger stderr thread - communicator.start_worker("test_script.py") - - # Manually call _read_stderr to test it - communicator._read_stderr() - - # Verify readline was called - assert mock_stderr.readline.call_count >= 1 - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_stderr_with_exception(self, mock_thread, mock_popen, communicator): - """Test _read_stderr handles exceptions gracefully.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - - # Mock stderr that raises exception - mock_stderr = MagicMock() - mock_stderr.readline.side_effect = Exception("Read error") - mock_process.stderr = mock_stderr - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should not raise exception - communicator._read_stderr() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_stderr_no_process(self, mock_thread, mock_popen, communicator): - """Test _read_stderr returns early when no process.""" - # Don't start worker, just call _read_stderr - communicator._read_stderr() - # Should return without error - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_valid_json(self, mock_thread, mock_popen, communicator): - """Test _read_responses processes valid JSON responses.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with valid JSON responses - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - '{"status": "ok", "request_id": "test-123"}\n', - "", # Empty string signals end - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Create a response queue for the request - communicator.response_queues["test-123"] = Queue() - - communicator.start_worker("test_script.py") - - # Manually call _read_responses - communicator._read_responses() - - # Verify the response was queued - assert not communicator.response_queues["test-123"].empty() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_empty_lines(self, mock_thread, mock_popen, communicator): - """Test _read_responses skips empty lines.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with empty lines - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - "\n", - " \n", - '{"status": "ok", "request_id": "test-456"}\n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.response_queues["test-456"] = Queue() - communicator.start_worker("test_script.py") - communicator._read_responses() - - assert not communicator.response_queues["test-456"].empty() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_invalid_json(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles invalid JSON gracefully.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with invalid JSON - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - "not valid json\n", - '{"incomplete": \n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should not raise exception - communicator._read_responses() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_without_request_id(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles responses without request_id.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with response missing request_id - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - '{"status": "ok", "data": "test"}\n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should log warning but not crash - communicator._read_responses() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_unknown_request_id(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles unknown request_id.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with unknown request_id - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - '{"status": "ok", "request_id": "unknown-999"}\n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should log warning but not crash - communicator._read_responses() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_exception(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles exceptions during reading.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout that raises exception - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = Exception("Read error") - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should handle exception and set running to False - communicator._read_responses() - assert communicator.running is False - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("cpex.framework.isolated.venv_comm.Queue") - def test_send_task_stdin_not_available(self, mock_queue_class, mock_thread, mock_popen, communicator): - """Test send_task when stdin is not available.""" - task_data = {"task_type": "test"} - - mock_process = MagicMock() - mock_process.stdin = None # stdin not available - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - mock_queue_instance = MagicMock() - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - with pytest.raises(RuntimeError, match="Worker process stdin not available"): - communicator.send_task("test_script.py", task_data) - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_stop_worker_send_shutdown_exception(self, mock_thread, mock_popen, communicator): - """Test stop_worker handles exception when sending shutdown signal.""" - mock_process = MagicMock() - mock_stdin = MagicMock() - mock_stdin.write.side_effect = Exception("Write failed") - mock_process.stdin = mock_stdin - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.wait.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread_instance.is_alive.return_value = False - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should handle exception gracefully - communicator.stop_worker() - - assert communicator.running is False - assert communicator.process is None - - def test_del_method(self, communicator): - """Test __del__ method calls stop_worker.""" - communicator.running = True - communicator.process = MagicMock() - - # Call __del__ directly - communicator.__del__() - - # Should have stopped the worker - assert communicator.running is False - - def test_del_method_no_running_attribute(self): - """Test __del__ handles missing running attribute.""" - # Create instance without proper initialization - comm = object.__new__(VenvProcessCommunicator) - - # Should not raise exception - comm.__del__() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_exceeds_max_content_size(self, mock_thread, mock_popen, communicator): - """Test send_task raises error when data exceeds max_content_size.""" - # Create a large task that will exceed the limit - large_data = "x" * 5000 - task_data = {"task_type": "test", "data": large_data} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Set a very small max_content_size to trigger the error - with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): - communicator.send_task("test_script.py", task_data, max_content_size=100) - - # Verify the request_id was cleaned up from response_queues - assert len(communicator.response_queues) == 0 - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("uuid.uuid4") - def test_send_task_at_max_content_size_boundary(self, mock_uuid, mock_thread, mock_popen, communicator): - """Test send_task works when data is exactly at the limit.""" - # Use a fixed UUID to make size calculation predictable - mock_uuid.return_value = Mock(hex="12345678123456781234567812345678") - mock_uuid.return_value.__str__ = Mock(return_value="12345678-1234-5678-1234-567812345678") - - task_data = {"task_type": "test", "data": "small"} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Mock the Queue to return response - with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = {"status": "success", "result": "ok", "request_id": "test-id"} - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - # Calculate the exact size of the serialized data with the mocked UUID - import orjson - - test_data_copy = task_data.copy() - test_data_copy["request_id"] = "12345678-1234-5678-1234-567812345678" - serialized_size = len(orjson.dumps(test_data_copy).decode()) - - # Set max_content_size to exactly the serialized size - result = communicator.send_task("test_script.py", task_data, max_content_size=serialized_size) - - assert result == {"status": "success", "result": "ok"} - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_with_custom_max_content_size(self, mock_thread, mock_popen, communicator): - """Test send_task respects custom max_content_size parameter.""" - # Create task data that's moderately sized - task_data = {"task_type": "test", "data": "x" * 1000, "metadata": {"key": "value"}} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = {"status": "success", "result": "processed", "request_id": "test-id"} - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - # Should succeed with large max_content_size - result = communicator.send_task("test_script.py", task_data, max_content_size=50000) - assert result == {"status": "success", "result": "processed"} - - # Should fail with small max_content_size - with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): - communicator.send_task("test_script.py", task_data, max_content_size=500) - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_default_max_content_size(self, mock_thread, mock_popen, communicator): - """Test send_task uses default max_content_size of 10MB.""" - # Create a task that's under 10MB - task_data = { - "task_type": "test", - "data": "x" * 100000, # 100KB - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = {"status": "success", "result": "ok", "request_id": "test-id"} - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - # Should succeed with default max_content_size (10MB) - result = communicator.send_task("test_script.py", task_data) - assert result == {"status": "success", "result": "ok"} - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_very_large_data_exceeds_default_limit(self, mock_thread, mock_popen, communicator): - """Test send_task fails when data exceeds default 10MB limit.""" - # Create a task that exceeds 10MB - task_data = { - "task_type": "test", - "data": "x" * 11000000, # ~11MB - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should fail with default max_content_size - with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): - communicator.send_task("test_script.py", task_data) - - # Verify cleanup happened - assert len(communicator.response_queues) == 0 - - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_stderr_with_output(self, mock_thread, mock_popen, communicator): - """Test _read_stderr method reads and logs stderr output.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - - # Mock stderr with some output - mock_stderr = MagicMock() - mock_stderr.readline.side_effect = [ - "Error line 1\n", - "Error line 2\n", - "", # Empty string signals end - ] - mock_process.stderr = mock_stderr - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Start worker to trigger stderr thread - communicator.start_worker("test_script.py") - - # Manually call _read_stderr to test it - communicator._read_stderr() - - # Verify readline was called - assert mock_stderr.readline.call_count >= 1 - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_stderr_with_exception(self, mock_thread, mock_popen, communicator): - """Test _read_stderr handles exceptions gracefully.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - - # Mock stderr that raises exception - mock_stderr = MagicMock() - mock_stderr.readline.side_effect = Exception("Read error") - mock_process.stderr = mock_stderr - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should not raise exception - communicator._read_stderr() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_stderr_no_process(self, mock_thread, mock_popen, communicator): - """Test _read_stderr returns early when no process.""" - # Don't start worker, just call _read_stderr - communicator._read_stderr() - # Should return without error - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_valid_json(self, mock_thread, mock_popen, communicator): - """Test _read_responses processes valid JSON responses.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with valid JSON responses - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - '{"status": "ok", "request_id": "test-123"}\n', - "", # Empty string signals end - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Create a response queue for the request - communicator.response_queues["test-123"] = Queue() - - communicator.start_worker("test_script.py") - - # Manually call _read_responses - communicator._read_responses() - - # Verify the response was queued - assert not communicator.response_queues["test-123"].empty() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_empty_lines(self, mock_thread, mock_popen, communicator): - """Test _read_responses skips empty lines.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with empty lines - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - "\n", - " \n", - '{"status": "ok", "request_id": "test-456"}\n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.response_queues["test-456"] = Queue() - communicator.start_worker("test_script.py") - communicator._read_responses() - - assert not communicator.response_queues["test-456"].empty() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_invalid_json(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles invalid JSON gracefully.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with invalid JSON - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - "not valid json\n", - '{"incomplete": \n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should not raise exception - communicator._read_responses() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_without_request_id(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles responses without request_id.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with response missing request_id - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - '{"status": "ok", "data": "test"}\n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should log warning but not crash - communicator._read_responses() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_unknown_request_id(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles unknown request_id.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout with unknown request_id - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = [ - '{"status": "ok", "request_id": "unknown-999"}\n', - "", - ] - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should log warning but not crash - communicator._read_responses() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_read_responses_with_exception(self, mock_thread, mock_popen, communicator): - """Test _read_responses handles exceptions during reading.""" - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stderr = MagicMock() - - # Mock stdout that raises exception - mock_stdout = MagicMock() - mock_stdout.readline.side_effect = Exception("Read error") - mock_process.stdout = mock_stdout - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should handle exception and set running to False - communicator._read_responses() - assert communicator.running is False - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("cpex.framework.isolated.venv_comm.Queue") - def test_send_task_stdin_not_available(self, mock_queue_class, mock_thread, mock_popen, communicator): - """Test send_task when stdin is not available.""" - task_data = {"task_type": "test"} - - mock_process = MagicMock() - mock_process.stdin = None # stdin not available - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - mock_queue_instance = MagicMock() - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - with pytest.raises(RuntimeError, match="Worker process stdin not available"): - communicator.send_task("test_script.py", task_data) - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_stop_worker_send_shutdown_exception(self, mock_thread, mock_popen, communicator): - """Test stop_worker handles exception when sending shutdown signal.""" - mock_process = MagicMock() - mock_stdin = MagicMock() - mock_stdin.write.side_effect = Exception("Write failed") - mock_process.stdin = mock_stdin - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.wait.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread_instance.is_alive.return_value = False - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should handle exception gracefully - communicator.stop_worker() - - assert communicator.running is False - assert communicator.process is None - - def test_del_method(self, communicator): - """Test __del__ method calls stop_worker.""" - communicator.running = True - communicator.process = MagicMock() - - # Call __del__ directly - communicator.__del__() - - # Should have stopped the worker - assert communicator.running is False - - def test_del_method_no_running_attribute(self): - """Test __del__ handles missing running attribute.""" - # Create instance without proper initialization - comm = object.__new__(VenvProcessCommunicator) - - # Should not raise exception - comm.__del__() - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_exceeds_max_content_size(self, mock_thread, mock_popen, communicator): - """Test send_task raises error when data exceeds max_content_size.""" - # Create a large task that will exceed the limit - large_data = "x" * 5000 - task_data = { - "task_type": "test", - "data": large_data - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Set a very small max_content_size to trigger the error - with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): - communicator.send_task("test_script.py", task_data, max_content_size=100) - - # Verify the request_id was cleaned up from response_queues - assert len(communicator.response_queues) == 0 - - @patch("subprocess.Popen") - @patch("threading.Thread") - @patch("uuid.uuid4") - def test_send_task_at_max_content_size_boundary(self, mock_uuid, mock_thread, mock_popen, communicator): - """Test send_task works when data is exactly at the limit.""" - # Use a fixed UUID to make size calculation predictable - mock_uuid.return_value = Mock(hex="12345678123456781234567812345678") - mock_uuid.return_value.__str__ = Mock(return_value="12345678-1234-5678-1234-567812345678") - - task_data = {"task_type": "test", "data": "small"} - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - # Mock the Queue to return response - with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = { - "status": "success", - "result": "ok", - "request_id": "test-id" - } - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - # Calculate the exact size of the serialized data with the mocked UUID - import orjson - test_data_copy = task_data.copy() - test_data_copy["request_id"] = "12345678-1234-5678-1234-567812345678" - serialized_size = len(orjson.dumps(test_data_copy).decode()) - - # Set max_content_size to exactly the serialized size - result = communicator.send_task("test_script.py", task_data, max_content_size=serialized_size) - - assert result == {"status": "success", "result": "ok"} - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_with_custom_max_content_size(self, mock_thread, mock_popen, communicator): - """Test send_task respects custom max_content_size parameter.""" - # Create task data that's moderately sized - task_data = { - "task_type": "test", - "data": "x" * 1000, - "metadata": {"key": "value"} - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = { - "status": "success", - "result": "processed", - "request_id": "test-id" - } - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - # Should succeed with large max_content_size - result = communicator.send_task("test_script.py", task_data, max_content_size=50000) - assert result == {"status": "success", "result": "processed"} - - # Should fail with small max_content_size - with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): - communicator.send_task("test_script.py", task_data, max_content_size=500) - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_default_max_content_size(self, mock_thread, mock_popen, communicator): - """Test send_task uses default max_content_size of 10MB.""" - # Create a task that's under 10MB - task_data = { - "task_type": "test", - "data": "x" * 100000 # 100KB - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: - mock_queue_instance = MagicMock() - mock_queue_instance.get.return_value = { - "status": "success", - "result": "ok", - "request_id": "test-id" - } - mock_queue_class.return_value = mock_queue_instance - - communicator.start_worker("test_script.py") - - # Should succeed with default max_content_size (10MB) - result = communicator.send_task("test_script.py", task_data) - assert result == {"status": "success", "result": "ok"} - - @patch("subprocess.Popen") - @patch("threading.Thread") - def test_send_task_very_large_data_exceeds_default_limit(self, mock_thread, mock_popen, communicator): - """Test send_task fails when data exceeds default 10MB limit.""" - # Create a task that exceeds 10MB - task_data = { - "task_type": "test", - "data": "x" * 11000000 # ~11MB - } - - mock_process = MagicMock() - mock_process.stdin = MagicMock() - mock_process.stdout = MagicMock() - mock_process.stderr = MagicMock() - mock_process.poll.return_value = None - mock_popen.return_value = mock_process - - mock_thread_instance = MagicMock() - mock_thread.return_value = mock_thread_instance - - communicator.start_worker("test_script.py") - - # Should fail with default max_content_size - with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): - communicator.send_task("test_script.py", task_data) - - # Verify cleanup happened - assert len(communicator.response_queues) == 0 - - -# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py deleted file mode 100644 index 3dcfb686..00000000 --- a/tests/unit/cpex/framework/isolated/test_worker.py +++ /dev/null @@ -1,452 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/isolated/test_worker.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Ted Habeck - -Unit tests for worker.py functions. -""" - -import json -import os -import shutil -import sys -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from cpex.framework.isolated.worker import TaskProcessor, get_environment_info, main, process_task - - -class TestWorkerFunctions: - """Test suite for worker.py functions.""" - - @pytest.fixture - def mock_plugin_dirs(self): - """ensure that the plugins directory exists""" - plugin_dirs = Path(os.getcwd()) / "tmp" / "plugins" - tmp = plugin_dirs - tmp.mkdir(parents=True, exist_ok=True) - return [str(plugin_dirs.resolve())] - - def cleanup_mock_plugin_dirs(self): - """Test cleanup for the mock plugin directories.""" - plugin_root = Path(os.getcwd()) / "tmp" - shutil.rmtree(plugin_root.resolve()) - - def test_get_environment_info(self): - """Test getting environment information.""" - info = get_environment_info() - - assert "python_version" in info - assert "python_executable" in info - assert "platform" in info - assert "installed_packages" in info - - assert info["python_version"] == sys.version - assert info["python_executable"] == sys.executable - assert isinstance(info["installed_packages"], list) - assert len(info["installed_packages"]) <= 10 # Limited to first 10 - - @pytest.mark.asyncio - async def test_process_task_info(self): - """Test processing info task.""" - config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} - task_data = {"task_type": "info", "config": json.dumps(config_dict)} - tp = TaskProcessor() - result = await process_task(task_data, tp) - - assert result["status"] == "success" - assert "environment" in result - assert "message" in result - assert result["message"] == "Environment info retrieved successfully" - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.worker.import_module") - @patch("cpex.framework.isolated.worker.PluginExecutor") - async def test_process_task_load_and_run_hook_success( - self, mock_executor_class, mock_import, mock_plugin_dirs - ): - """Test processing load_and_run_hook task successfully.""" - - # Setup mock plugin class - mock_plugin_instance = AsyncMock() - mock_plugin_instance.initialize = AsyncMock() - mock_plugin_instance.tool_pre_invoke = AsyncMock() - mock_plugin_instance.tool_post_invoke = AsyncMock() - mock_plugin_instance.tool_exception = AsyncMock() - mock_plugin_instance.tool_cleanup = AsyncMock() - mock_plugin_class = MagicMock(return_value=mock_plugin_instance) - - mock_module = MagicMock() - mock_module.TestPlugin = mock_plugin_class - mock_import.return_value = mock_module - - # Setup mock executor - mock_executor = MagicMock() - mock_result = MagicMock() - mock_result.continue_processing = True - mock_executor.execute_plugin = AsyncMock(return_value=mock_result) - mock_executor_class.return_value = mock_executor - - # Create task data - config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} - task_data = { - "task_type": "load_and_run_hook", - "config": json.dumps(config_dict), - "plugin_dirs": mock_plugin_dirs, - "class_name": "test_plugin.TestPlugin", - "hook_type": "tool_pre_invoke", - "payload": {"name": "test_tool", "args": {}}, - "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, - } - tp = TaskProcessor() - result = await process_task(task_data, tp=tp) - - assert result is not None - mock_plugin_instance.initialize.assert_called_once() - mock_executor.execute_plugin.assert_called_once() - self.cleanup_mock_plugin_dirs() - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.worker.import_module") - async def test_process_task_load_and_run_hook_import_error(self, mock_import, mock_plugin_dirs): - """Test processing load_and_run_hook task with import error.""" - mock_import.side_effect = ImportError("Module not found") - - config_dict = {"name": "test_plugin", "kind": "isolated_venv"} - task_data = { - "task_type": "load_and_run_hook", - "config": json.dumps(config_dict), - "class_name": "test_plugin.TestPlugin", - "plugin_dirs": mock_plugin_dirs, - "hook_type": "tool_pre_invoke", - "payload": {}, - "context": {"state": {}, "global_context": {}, "metadata": {}}, - } - tp = TaskProcessor() - with pytest.raises(ImportError): - await process_task(task_data, tp) - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.worker.import_module") - @patch("cpex.framework.isolated.worker.PluginExecutor") - async def test_process_task_with_different_hook_types( - self, mock_executor_class, mock_import, mock_plugin_dirs - ): - """Test processing tasks with different hook types.""" - - mock_plugin_instance = MagicMock() - mock_plugin_instance.initialize = AsyncMock() - mock_plugin_instance.tool_pre_invoke = AsyncMock() - mock_plugin_instance.tool_post_invoke = AsyncMock() - mock_plugin_instance.prompt_pre_fetch = AsyncMock() - mock_plugin_instance.prompt_post_fetch = AsyncMock() - mock_plugin_instance.tool_exception = AsyncMock() - mock_plugin_instance.tool_cleanup = AsyncMock() - mock_plugin_class = MagicMock(return_value=mock_plugin_instance) - - mock_module = MagicMock() - mock_module.TestPlugin = mock_plugin_class - mock_import.return_value = mock_module - - mock_executor = MagicMock() - mock_result = MagicMock() - mock_executor.execute_plugin = AsyncMock(return_value=mock_result) - mock_executor_class.return_value = mock_executor - - hook_types = ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"] - tp = TaskProcessor() - - for hook_type in hook_types: - config_dict = {"name": "test_plugin", "kind": "isolated_venv"} - task_data = { - "task_type": "load_and_run_hook", - "config": json.dumps(config_dict), - "plugin_dirs": mock_plugin_dirs, - "class_name": "test_plugin.TestPlugin", - "hook_type": hook_type, - "payload": {}, - "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, - } - result = await process_task(task_data, tp) - assert result is not None - self.cleanup_mock_plugin_dirs() - - @pytest.mark.asyncio - async def test_process_task_unknown_task_type(self): - """Test processing task with unknown task type.""" - task_data = {"task_type": "unknown_type"} - tp = TaskProcessor() - # Should return None or handle gracefully - result = await process_task(task_data, tp) - assert result == {"message": "task type not supported.", "request_id": "unknown", "status": "error"} - - @pytest.mark.asyncio - @patch("cpex.framework.isolated.worker.import_module") - @patch("cpex.framework.isolated.worker.PluginExecutor") - async def test_process_task_with_metadata( - self, mock_executor_class, mock_import, mock_plugin_dirs - ): - """Test processing task with metadata in context.""" - - mock_plugin_instance = AsyncMock() - mock_plugin_instance.initialize = AsyncMock() - mock_plugin_instance.tool_pre_invoke = AsyncMock() - mock_plugin_instance.tool_post_invoke = AsyncMock() - mock_plugin_instance.prompt_pre_fetch = AsyncMock() - mock_plugin_instance.prompt_post_fetch = AsyncMock() - mock_plugin_instance.tool_exception = AsyncMock() - mock_plugin_instance.tool_cleanup = AsyncMock() - - mock_plugin_class = MagicMock(return_value=mock_plugin_instance) - - mock_module = MagicMock() - mock_module.TestPlugin = mock_plugin_class - mock_import.return_value = mock_module - - mock_executor = MagicMock() - mock_result = MagicMock() - mock_executor.execute_plugin = AsyncMock(return_value=mock_result) - mock_executor_class.return_value = mock_executor - - config_dict = {"name": "test_plugin", "kind": "isolated_venv"} - task_data = { - "task_type": "load_and_run_hook", - "config": json.dumps(config_dict), - "class_name": "test_plugin.TestPlugin", - "plugin_dirs": mock_plugin_dirs, - "hook_type": "tool_pre_invoke", - "payload": {"name": "test_tool"}, - "context": { - "state": {"key": "value"}, - "global_context": {"request_id": "req-123", "user": "alice"}, - "metadata": {"custom": "data"}, - }, - } - tp = TaskProcessor() - - result = await process_task(task_data, tp) - - assert result is not None - # Verify executor was called with proper context - call_args = mock_executor.execute_plugin.call_args - assert call_args is not None - self.cleanup_mock_plugin_dirs() - - -class TestMainFunction: - """Test suite for the main() function.""" - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_success_with_info_task(self, mock_process_task, mock_print, mock_stdin): - """Test main function with successful info task.""" - # Setup stdin to return one task then EOF - task_data = {"task_type": "info", "request_id": "req-123"} - mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # EOF after first task - - # Setup process_task to return a mock result - mock_result = MagicMock() - mock_result.model_dump.return_value = { - "status": "success", - "environment": {"python_version": "3.10"}, - "message": "Environment info retrieved successfully", - } - mock_process_task.return_value = mock_result - - # Run main - await main() - - # Verify process_task was called with correct data - mock_process_task.assert_called_once() - call_args = mock_process_task.call_args[0][0] - assert call_args["task_type"] == "info" - assert call_args["request_id"] == "req-123" - - # Verify output was printed with request_id - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] - output_data = json.loads(printed_output) - assert output_data["status"] == "success" - assert output_data["request_id"] == "req-123" - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_success_with_none_result(self, mock_process_task, mock_print, mock_stdin): - """Test main function when process_task returns None.""" - task_data = {"task_type": "unknown", "request_id": "req-456"} - mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] - - # process_task returns None for unknown task types - mock_process_task.return_value = None - - await main() - - mock_process_task.assert_called_once() - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] - output_data = json.loads(printed_output) - # Should have success status and request_id - assert output_data["status"] == "success" - assert output_data["request_id"] == "req-456" - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - async def test_main_json_decode_error(self, mock_print, mock_stdin): - """Test main function with invalid JSON input.""" - # Setup stdin with invalid JSON then EOF - mock_stdin.readline.side_effect = ["not valid json {{", ""] - - await main() - - # Verify error response was printed - mock_print.assert_called() - printed_output = mock_print.call_args_list[0][0][0] - output_data = json.loads(printed_output) - assert output_data["status"] == "error" - assert "Invalid JSON input" in output_data["message"] - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_unexpected_exception(self, mock_process_task, mock_print, mock_stdin): - """Test main function with unexpected exception during processing.""" - task_data = {"task_type": "load_and_run_hook", "request_id": "req-789"} - mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] - - # Make process_task raise an exception - mock_process_task.side_effect = RuntimeError("Unexpected error occurred") - - await main() - - # Verify error response was printed - mock_print.assert_called() - printed_output = mock_print.call_args_list[0][0][0] - output_data = json.loads(printed_output) - assert output_data["status"] == "error" - assert "Unexpected error: Unexpected error occurred" in output_data["message"] - assert output_data["request_id"] == "unknown" - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_with_load_and_run_hook_task(self, mock_process_task, mock_print, mock_stdin): - """Test main function with load_and_run_hook task.""" - config_dict = {"name": "test_plugin", "kind": "isolated_venv"} - task_data = { - "task_type": "load_and_run_hook", - "config": json.dumps(config_dict), - "class_name": "test_plugin.TestPlugin", - "hook_type": "tool_pre_invoke", - "payload": {"name": "test_tool"}, - "context": {"state": {}, "global_context": {}, "metadata": {}}, - "request_id": "req-abc", - } - mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] - - # Setup mock result - mock_result = MagicMock() - mock_result.model_dump.return_value = { - "continue_processing": True, - "payload": {"name": "test_tool", "modified": True}, - "violations": [], - } - mock_process_task.return_value = mock_result - - await main() - - mock_process_task.assert_called_once() - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] - output_data = json.loads(printed_output) - assert output_data["continue_processing"] is True - assert output_data["request_id"] == "req-abc" - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - async def test_main_with_empty_line(self, mock_print, mock_stdin): - """Test main function with empty line (EOF).""" - mock_stdin.readline.return_value = "" - - await main() - - # Should exit gracefully without printing error - # (may not print anything if EOF is first thing read) - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_with_model_dump_exception(self, mock_process_task, mock_print, mock_stdin): - """Test main function when model_dump raises an exception.""" - task_data = {"task_type": "info", "request_id": "req-error"} - mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] - - # Setup mock result that raises exception on model_dump - mock_result = MagicMock() - mock_result.model_dump.side_effect = ValueError("Cannot serialize") - mock_process_task.return_value = mock_result - - await main() - - # Should catch the exception and return error - mock_print.assert_called() - printed_output = mock_print.call_args_list[0][0][0] - output_data = json.loads(printed_output) - assert output_data["status"] == "error" - assert "Unexpected error" in output_data["message"] - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - async def test_main_with_shutdown_signal(self, mock_print, mock_stdin): - """Test main function with shutdown signal.""" - task_data = {"task_type": "shutdown", "request_id": "shutdown"} - mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] - - await main() - - # Should print shutdown response and exit - mock_print.assert_called_once() - printed_output = mock_print.call_args[0][0] - output_data = json.loads(printed_output) - assert output_data["status"] == "success" - assert output_data["message"] == "Shutting down" - assert output_data["request_id"] == "shutdown" - - @pytest.mark.asyncio - @patch("sys.stdin") - @patch("builtins.print") - @patch("cpex.framework.isolated.worker.process_task") - async def test_main_multiple_tasks(self, mock_process_task, mock_print, mock_stdin): - """Test main function processing multiple tasks.""" - task1 = {"task_type": "info", "request_id": "req-1"} - task2 = {"task_type": "info", "request_id": "req-2"} - mock_stdin.readline.side_effect = [ - json.dumps(task1) + "\n", - json.dumps(task2) + "\n", - "", # EOF - ] - - mock_result = MagicMock() - mock_result.model_dump.return_value = {"status": "success"} - mock_process_task.return_value = mock_result - - await main() - - # Should process both tasks - assert mock_process_task.call_count == 2 - assert mock_print.call_count == 2 - - -# Made with Bob diff --git a/tests/unit/cpex/framework/loader/__init__.py b/tests/unit/cpex/framework/loader/__init__.py deleted file mode 100644 index 6233a63b..00000000 --- a/tests/unit/cpex/framework/loader/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/loader/__init__.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor -""" diff --git a/tests/unit/cpex/framework/loader/test_plugin_loader.py b/tests/unit/cpex/framework/loader/test_plugin_loader.py deleted file mode 100644 index 08803c09..00000000 --- a/tests/unit/cpex/framework/loader/test_plugin_loader.py +++ /dev/null @@ -1,315 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/loader/test_plugin_loader.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for config and plugin loaders. -""" - -# Standard -from unittest.mock import patch - -import pytest - -# Third-Party -from pydantic import ValidationError - -from cpex.framework import GlobalContext, PluginContext, PluginMode, PromptPosthookPayload, PromptPrehookPayload -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE -from cpex.framework.external.mcp.client import ExternalPlugin - -# First-Party -from cpex.framework.loader.config import ConfigLoader -from cpex.framework.loader.plugin import PluginLoader -from cpex.framework.models import PluginConfig -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, Role, TextContent -from tests.unit.cpex.fixtures.plugins.search_replace import SearchReplaceConfig, SearchReplacePlugin - - -def test_config_loader_load(): - """pytest for testing the config loader.""" - config = ConfigLoader.load_config(config="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - assert config - assert len(config.plugins) == 1 - assert config.plugins[0].name == "ReplaceBadWordsPlugin" - assert config.plugins[0].kind == "plugins.search_replace.SearchReplacePlugin" - assert config.plugins[0].description == "A plugin for finding and replacing words." - assert config.plugins[0].version == "0.1" - assert config.plugins[0].author == "ContextForge Team" - assert config.plugins[0].hooks[0] == "prompt_pre_fetch" - assert config.plugins[0].hooks[1] == "prompt_post_fetch" - assert config.plugins[0].config - srconfig = SearchReplaceConfig.model_validate(config.plugins[0].config) - assert len(srconfig.words) == 2 - assert srconfig.words[0].search == "crap" - assert srconfig.words[0].replace == "crud" - - -@pytest.mark.asyncio -async def test_plugin_loader_load(monkeypatch): - """Load a plugin with the plugin loader.""" - config = ConfigLoader.load_config(config="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - loader = PluginLoader() - loader.append_to_search_path(config.plugin_dirs) - - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - assert type(plugin).__name__ == "SearchReplacePlugin" - assert plugin.name == "ReplaceBadWordsPlugin" - assert plugin.mode == PluginMode.TRANSFORM - assert plugin.priority == 150 - assert "test_prompt" in plugin.conditions[0].prompts - assert plugin.hooks[0] == "prompt_pre_fetch" - assert plugin.hooks[1] == "prompt_post_fetch" - - context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - result = await plugin.prompt_pre_fetch(prompt, context=context) - assert len(result.modified_payload.args) == 1 - assert result.modified_payload.args["user"] == "What a yikesshow!" - - message = Message(content=TextContent(type="text", text="What the crud?"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result = await plugin.prompt_post_fetch(payload_result, context) - assert len(result.modified_payload.result.messages) == 1 - assert result.modified_payload.result.messages[0].content.text == "What the yikes?" - - await loader.shutdown() - - -@pytest.mark.asyncio -async def test_plugin_loader_invalid_plugin_load(): - """Load an invalid plugin with the plugin loader.""" - config = ConfigLoader.load_config( - config="./tests/unit/cpex/fixtures/configs/invalid_single_plugin.yaml", use_jinja=False - ) - loader = PluginLoader() - loader.append_to_search_path(config.plugin_dirs) - with pytest.raises(ModuleNotFoundError): - await loader.load_and_instantiate_plugin(config.plugins[0]) - - -@pytest.mark.asyncio -async def test_plugin_loader_duplicate_registration(): - """Test that duplicate plugin type registration is handled correctly.""" - config = ConfigLoader.load_config(config="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - loader = PluginLoader() - loader.append_to_search_path(config.plugin_dirs) - - # Load the same plugin twice to test the "if kind not in self._plugin_types" path (line 72) - plugin1 = await loader.load_and_instantiate_plugin(config.plugins[0]) - plugin2 = await loader.load_and_instantiate_plugin(config.plugins[0]) - - # Both should be instances of the same type - assert type(plugin1) is type(plugin2) - assert type(plugin1).__name__ == "SearchReplacePlugin" - assert type(plugin2).__name__ == "SearchReplacePlugin" - - # Verify the plugin type was only registered once - assert len(loader._plugin_types) == 1 - assert config.plugins[0].kind in loader._plugin_types - - await loader.shutdown() - - -@pytest.mark.asyncio -async def test_plugin_loader_get_plugin_type_error(): - """Test error handling in __get_plugin_type method.""" - # First-Party - from cpex.framework.models import PluginConfig - - loader = PluginLoader() - - # Create a config with an invalid plugin kind that will cause an import error - invalid_config = PluginConfig( - name="InvalidPlugin", - description="Test invalid plugin", - author="Test Author", - version="1.0", - tags=["test"], - kind="nonexistent.module.InvalidPlugin", - hooks=["prompt_pre_fetch"], - config={}, - ) - - # This should raise an exception during plugin type registration - with pytest.raises(Exception): # Could be ModuleNotFoundError or other import-related error - await loader.load_and_instantiate_plugin(invalid_config) - - await loader.shutdown() - - -@pytest.mark.asyncio -async def test_plugin_loader_none_plugin_type(): - """Test handling when plugin type resolves to None.""" - # First-Party - from cpex.framework.models import PluginConfig - - loader = PluginLoader() - - # Mock the _plugin_types to return None for a specific kind - test_config = PluginConfig( - name="TestPlugin", - description="Test plugin", - author="Test Author", - version="1.0", - tags=["test"], - kind="test.plugin.TestPlugin", - hooks=["prompt_pre_fetch"], - config={}, - ) - - # Manually set plugin type to None to test line 90 (return None) - with patch.object(loader, "_PluginLoader__get_plugin_type") as mock_get_type: - mock_get_type.return_value = None - loader._plugin_types[test_config.kind] = None - - result = await loader.load_and_instantiate_plugin(test_config) - assert result is None # Should return None when plugin_type is None - - await loader.shutdown() - - -@pytest.mark.asyncio -async def test_plugin_loader_shutdown_with_empty_types(): - """Test shutdown when _plugin_types is empty.""" - loader = PluginLoader() - - # Start with empty plugin types - assert len(loader._plugin_types) == 0 - - # Shutdown should handle empty dict gracefully (line 94: if self._plugin_types) - await loader.shutdown() - - # Should still be empty - assert len(loader._plugin_types) == 0 - - -@pytest.mark.asyncio -async def test_plugin_loader_shutdown_with_existing_types(): - """Test shutdown clears existing plugin types.""" - config = ConfigLoader.load_config(config="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - loader = PluginLoader() - loader.append_to_search_path(config.plugin_dirs) - - # Load a plugin to populate _plugin_types - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - assert plugin is not None - assert len(loader._plugin_types) == 1 - - # Shutdown should clear the dict - await loader.shutdown() - assert len(loader._plugin_types) == 0 - - -@pytest.mark.asyncio -async def test_plugin_loader_registration_branch_coverage(): - """Test plugin registration path coverage.""" - # First-Party - from cpex.framework.models import PluginConfig - - loader = PluginLoader() - - # Create a valid config - config = PluginConfig( - name="TestPlugin", - description="Test plugin for registration", - author="Test Author", - version="1.0", - tags=["test"], - kind="plugins.search_replace.SearchReplacePlugin", - hooks=["prompt_pre_fetch"], - config={"words": [{"search": "test", "replace": "example"}]}, - ) - - # Add plugin dirs to search path so importlib can resolve the kind - loader.append_to_search_path(["tests/unit/cpex/fixtures"]) - - # First load - should register the plugin type (lines 85-87) - assert config.kind not in loader._plugin_types # Verify it's not registered yet - plugin1 = await loader.load_and_instantiate_plugin(config) - assert plugin1 is not None - assert config.kind in loader._plugin_types # Now it should be registered - - # Second load - should skip registration (line 72 condition is false) - plugin2 = await loader.load_and_instantiate_plugin(config) - assert plugin2 is not None - assert len(loader._plugin_types) == 1 # Still only one type registered - - await loader.shutdown() - - -def test_register_external_plugin_type_branch(): - """Cover EXTERNAL_PLUGIN_TYPE registration branch.""" - loader = PluginLoader() - - loader._PluginLoader__register_plugin_type(EXTERNAL_PLUGIN_TYPE) - - assert loader._plugin_types[EXTERNAL_PLUGIN_TYPE] is ExternalPlugin - - -def test_register_plugin_type_noop_when_already_registered(): - """Cover no-op branch when plugin type already registered.""" - loader = PluginLoader() - loader._plugin_types["existing.kind.Plugin"] = SearchReplacePlugin - - loader._PluginLoader__register_plugin_type("existing.kind.Plugin") - - assert loader._plugin_types["existing.kind.Plugin"] is SearchReplacePlugin - - -@pytest.mark.asyncio -async def test_external_plugin_requires_transport(): - """External plugin config validation rejects missing transport details.""" - with pytest.raises(ValidationError, match="must have 'mcp', 'grpc', or 'unix_socket'"): - PluginConfig( - name="ExternalNoTransport", - description="Missing transport", - author="Test", - version="1.0", - tags=[], - kind=EXTERNAL_PLUGIN_TYPE, - hooks=["prompt_pre_fetch"], - config={}, - mcp=None, - grpc=None, - unix_socket=None, - ) - - -@pytest.mark.asyncio -async def test_external_plugin_mcp_transport_path(monkeypatch): - """Cover ExternalPlugin MCP transport instantiation and return path.""" - - class DummyExternalPlugin: - def __init__(self, config): - self.config = config - self.initialized = False - - async def initialize(self): - self.initialized = True - - monkeypatch.setattr("cpex.framework.loader.plugin.ExternalPlugin", DummyExternalPlugin) - - cfg = PluginConfig( - name="ExternalMcpPlugin", - description="External MCP plugin", - author="Test", - version="1.0", - tags=[], - kind=EXTERNAL_PLUGIN_TYPE, - hooks=["prompt_pre_fetch"], - config=None, - mcp={"proto": "SSE", "url": "http://example.com/mcp"}, - grpc=None, - unix_socket=None, - ) - - loader = PluginLoader() - plugin = await loader.load_and_instantiate_plugin(cfg) - - assert isinstance(plugin, DummyExternalPlugin) - assert plugin.initialized is True diff --git a/tests/unit/cpex/framework/test_content_type_matching.py b/tests/unit/cpex/framework/test_content_type_matching.py deleted file mode 100644 index e844afc1..00000000 --- a/tests/unit/cpex/framework/test_content_type_matching.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Unit tests for content type matching in plugin framework. - -Tests the content_types condition matching functionality added to fix issue #3925. -""" - -from cpex.framework import GlobalContext, PluginCondition -from cpex.framework.utils import matches, normalize_content_type - - -class TestNormalizeContentType: - """Test content type normalization helper function.""" - - def test_normalize_content_type_basic(self): - """Test basic content type normalization.""" - assert normalize_content_type("application/json") == "application/json" - assert normalize_content_type("text/html") == "text/html" - assert normalize_content_type("text/plain") == "text/plain" - - def test_normalize_content_type_with_charset(self): - """Test normalization strips charset parameter.""" - assert normalize_content_type("application/json; charset=utf-8") == "application/json" - assert normalize_content_type("text/html; charset=iso-8859-1") == "text/html" - assert normalize_content_type("text/plain;charset=utf-8") == "text/plain" - - def test_normalize_content_type_case_insensitive(self): - """Test case-insensitive normalization.""" - assert normalize_content_type("APPLICATION/JSON") == "application/json" - assert normalize_content_type("Text/Plain") == "text/plain" - assert normalize_content_type("TEXT/HTML") == "text/html" - - def test_normalize_content_type_with_multiple_parameters(self): - """Test normalization with multiple parameters.""" - assert normalize_content_type("application/json; charset=utf-8; boundary=something") == "application/json" - assert normalize_content_type("multipart/form-data; boundary=----WebKitFormBoundary") == "multipart/form-data" - - def test_normalize_content_type_with_spaces(self): - """Test normalization handles extra spaces.""" - assert normalize_content_type("application/json ; charset=utf-8") == "application/json" - assert normalize_content_type("text/plain ; charset=utf-8") == "text/plain" - - -class TestContentTypeMatching: - """Test content_types condition matching in matches() function.""" - - def test_matches_content_type_single_match(self): - """Test matching single content type.""" - condition = PluginCondition(content_types=["application/json"]) - context = GlobalContext(request_id="req1", content_type="application/json") - assert matches(condition, context) is True - - def test_matches_content_type_single_no_match(self): - """Test non-matching single content type.""" - condition = PluginCondition(content_types=["application/json"]) - context = GlobalContext(request_id="req1", content_type="text/plain") - assert matches(condition, context) is False - - def test_matches_content_type_multiple_match(self): - """Test matching one of multiple content types.""" - condition = PluginCondition(content_types=["application/json", "text/plain"]) - context = GlobalContext(request_id="req1", content_type="text/plain") - assert matches(condition, context) is True - - def test_matches_content_type_multiple_no_match(self): - """Test non-matching multiple content types.""" - condition = PluginCondition(content_types=["application/json", "application/xml"]) - context = GlobalContext(request_id="req1", content_type="text/plain") - assert matches(condition, context) is False - - def test_matches_content_type_with_charset(self): - """Test matching ignores charset parameter.""" - condition = PluginCondition(content_types=["application/json"]) - context = GlobalContext(request_id="req1", content_type="application/json; charset=utf-8") - assert matches(condition, context) is True - - def test_matches_content_type_case_insensitive(self): - """Test case-insensitive content type matching.""" - condition = PluginCondition(content_types=["application/json"]) - context = GlobalContext(request_id="req1", content_type="APPLICATION/JSON") - assert matches(condition, context) is True - - def test_matches_content_type_none_context(self): - """Test strict AND logic: condition fails when context.content_type is None but required.""" - condition = PluginCondition(content_types=["application/json"]) - context = GlobalContext(request_id="req1", content_type=None) - # Strict AND logic - plugin should NOT execute when content_type is None but required - assert matches(condition, context) is False - - def test_matches_content_type_empty_list(self): - """Test empty content_types list matches everything.""" - condition = PluginCondition(content_types=[]) - context = GlobalContext(request_id="req1", content_type="application/json") - assert matches(condition, context) is True - - def test_matches_content_type_none_condition(self): - """Test None content_types condition matches everything.""" - condition = PluginCondition(content_types=None) - context = GlobalContext(request_id="req1", content_type="application/json") - assert matches(condition, context) is True - - def test_matches_content_type_combined_with_server_id(self): - """Test content_types combined with server_ids condition.""" - condition = PluginCondition(server_ids={"srv1"}, content_types=["application/json"]) - - # Both match - context1 = GlobalContext(request_id="req1", server_id="srv1", content_type="application/json") - assert matches(condition, context1) is True - - # Server ID mismatch - context2 = GlobalContext(request_id="req2", server_id="srv2", content_type="application/json") - assert matches(condition, context2) is False - - # Content type mismatch - context3 = GlobalContext(request_id="req3", server_id="srv1", content_type="text/plain") - assert matches(condition, context3) is False - - def test_matches_content_type_combined_with_tenant_id(self): - """Test content_types combined with tenant_ids condition.""" - condition = PluginCondition(tenant_ids={"tenant1"}, content_types=["application/json"]) - - # Both match - context1 = GlobalContext(request_id="req1", tenant_id="tenant1", content_type="application/json") - assert matches(condition, context1) is True - - # Tenant ID mismatch - context2 = GlobalContext(request_id="req2", tenant_id="tenant2", content_type="application/json") - assert matches(condition, context2) is False - - def test_matches_content_type_combined_with_user_patterns(self): - """Test content_types combined with user_patterns condition.""" - condition = PluginCondition(user_patterns=["admin"], content_types=["application/json"]) - - # Both match - context1 = GlobalContext(request_id="req1", user="admin_user", content_type="application/json") - assert matches(condition, context1) is True - - # User pattern mismatch - context2 = GlobalContext(request_id="req2", user="regular_user", content_type="application/json") - assert matches(condition, context2) is False - - def test_matches_content_type_all_conditions_combined(self): - """Test content_types with all other conditions.""" - condition = PluginCondition( - server_ids={"srv1"}, tenant_ids={"tenant1"}, user_patterns=["admin"], content_types=["application/json"] - ) - - # All match - context1 = GlobalContext( - request_id="req1", server_id="srv1", tenant_id="tenant1", user="admin_user", content_type="application/json" - ) - assert matches(condition, context1) is True - - # One condition fails - context2 = GlobalContext( - request_id="req2", server_id="srv1", tenant_id="tenant1", user="admin_user", content_type="text/plain" - ) - assert matches(condition, context2) is False - - def test_matches_content_type_multipart_form_data(self): - """Test matching multipart/form-data content type.""" - condition = PluginCondition(content_types=["multipart/form-data"]) - context = GlobalContext(request_id="req1", content_type="multipart/form-data; boundary=----WebKitFormBoundary") - assert matches(condition, context) is True - - def test_matches_content_type_xml(self): - """Test matching XML content types.""" - condition = PluginCondition(content_types=["application/xml", "text/xml"]) - - context1 = GlobalContext(request_id="req1", content_type="application/xml") - assert matches(condition, context1) is True - - context2 = GlobalContext(request_id="req2", content_type="text/xml") - assert matches(condition, context2) is True - - def test_matches_backward_compatibility_no_content_type_field(self): - """Test backward compatibility when content_type is not set.""" - condition = PluginCondition(content_types=["application/json"]) - # Create context without content_type (defaults to None) - context = GlobalContext(request_id="req1") - # Strict AND logic - plugin should NOT execute when content_type is None but required - assert matches(condition, context) is False diff --git a/tests/unit/cpex/framework/test_context.py b/tests/unit/cpex/framework/test_context.py deleted file mode 100644 index b2f97af9..00000000 --- a/tests/unit/cpex/framework/test_context.py +++ /dev/null @@ -1,157 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_context.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Tests for context passing plugins. -""" - -import pytest - -from cpex.framework import ( - GlobalContext, - PluginManager, - ToolHookType, - ToolPostInvokePayload, - ToolPreInvokePayload, -) - - -@pytest.mark.asyncio -async def test_shared_context_across_pre_post_hooks(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/context_plugin.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke with transformation - use correct tool name from config - tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - assert len(contexts) == 1 - context = next(iter(contexts.values())) - assert context.state - assert "key2" in context.state - assert context.state["key2"] == "value2" - assert context.global_context.state["globkey1"] == "globvalue1" - assert len(context.global_context.state) - assert not context.global_context.metadata - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is None - - # Test tool post-invoke with transformation - tool_result_payload = ToolPostInvokePayload( - name="test_tool", result={"output": "Result was bad", "status": "wrong format"} - ) - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_result_payload, global_context=global_context, local_contexts=contexts - ) - - assert len(contexts) == 1 - context = next(iter(contexts.values())) - assert context.state - assert len(context.state) == 2 - assert "key2" in context.state - assert context.state["key2"] == "value2" - assert context.state["key3"] == "value3" - assert context.global_context.state - assert context.global_context.state["globkey1"] == "globvalue1" - assert context.global_context.state["globkey2"] == "globvalue2" - assert len(context.global_context.state) == 2 - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is None - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_shared_context_across_pre_post_hooks_multi_plugins(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/context_multiplugins.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke with transformation - use correct tool name from config - tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - assert len(contexts) == 2 - ctxs = [contexts[key] for key in contexts.keys()] - assert len(ctxs) == 2 - context1 = ctxs[0] - context2 = ctxs[1] - assert context1.state - assert "key2" in context1.state - assert "cp2key1" not in context1.state - assert context1.state["key2"] == "value2" - assert len(context1.state) == 1 - assert context1.global_context.state["globkey1"] == "globvalue1" - assert "gcp2globkey1" not in context1.global_context.state - assert len(context1.global_context.state) - assert not context1.global_context.metadata - - assert context2.state - assert len(context2.state) == 1 - assert "cp2key1" in context2.state - assert "key2" not in context2.state - assert context2.global_context.state["globkey1"] == "globvalue1" - assert context2.global_context.state["gcp2globkey1"] == "gcp2globvalue1" - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is None - # Test tool post-invoke with transformation - tool_result_payload = ToolPostInvokePayload( - name="test_tool", result={"output": "Result was bad", "status": "wrong format"} - ) - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_result_payload, global_context=global_context, local_contexts=contexts - ) - - ctxs = [contexts[key] for key in contexts.keys()] - assert len(ctxs) == 2 - context1 = ctxs[0] - context2 = ctxs[1] - assert context1.state - assert len(context1.state) == 2 - assert context1.state["key3"] == "value3" - assert context1.state["key2"] == "value2" - assert "cp2key1" not in context1.state - assert "cp2key2" not in context1.state - assert context1.global_context.state["globkey1"] == "globvalue1" - # gcp2globkey1 is set by ContextPlugin2 (AUDIT); it is not merged to global_context - assert "gcp2globkey1" not in context1.global_context.state - assert "gcp2globkey2" not in context1.global_context.state - assert context1.global_context.state["globkey2"] == "globvalue2" - - assert context2.global_context.state["globkey1"] == "globvalue1" - # gcp2globkey1 is not propagated from the first call since AUDIT does not merge global state - assert "gcp2globkey1" not in context2.global_context.state - assert context2.global_context.state["gcp2globkey2"] == "gcp2globvalue2" - assert context2.global_context.state["globkey2"] == "globvalue2" - - assert "key3" not in context2.state - assert "key2" not in context2.state - assert "cp2key1" in context2.state - """ - assert "key2" in context.state - assert context.state["key2"] == "value2" - assert context.state["key3"] == "value3" - assert context.global_context.state - assert context.global_context.state["globkey1"] == "globvalue1" - assert context.global_context.state["globkey2"] == "globvalue2" - assert len(context.global_context.state) == 2 - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is None - """ - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_errors.py b/tests/unit/cpex/framework/test_errors.py deleted file mode 100644 index 242f782f..00000000 --- a/tests/unit/cpex/framework/test_errors.py +++ /dev/null @@ -1,71 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_errors.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Tests for errors module. -""" - -# Third-Party -import re - -import pytest - -from cpex.framework import ( - GlobalContext, - OnError, - PluginError, - PluginManager, - PluginMode, - PromptHookType, - PromptPrehookPayload, -) -from cpex.framework.errors import convert_exception_to_error - - -@pytest.mark.asyncio -async def test_convert_exception_to_error(): - error_model = convert_exception_to_error(ValueError("This is some error."), "SomePluginName") - assert error_model.message == "ValueError('This is some error.')" - assert error_model.plugin_name == "SomePluginName" - - plugin_error = PluginError(error_model) - - assert plugin_error.error.message == "ValueError('This is some error.')" - assert plugin_error.error.plugin_name == "SomePluginName" - - -@pytest.mark.asyncio -async def test_error_plugin(): - plugin_manager = PluginManager(config="tests/unit/cpex/fixtures/configs/error_plugin.yaml") - await plugin_manager.initialize() - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is a crap argument"}) - global_context = GlobalContext(request_id="1") - escaped_regex = re.escape("ValueError('Sadly! Prompt prefetch is broken!')") - with pytest.raises(PluginError, match=escaped_regex): - await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - await plugin_manager.shutdown() - - -@pytest.mark.asyncio -async def test_error_plugin_raise_error_false(): - plugin_manager = PluginManager(config="tests/unit/cpex/fixtures/configs/error_plugin_raise_error_false.yaml") - await plugin_manager.initialize() - payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is a crap argument"}) - global_context = GlobalContext(request_id="1") - with pytest.raises(PluginError): - result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - # assert result.continue_processing - # assert not result.modified_payload - - await plugin_manager.shutdown() - plugin_manager.config.plugins[0] = plugin_manager.config.plugins[0].model_copy( - update={"mode": PluginMode.CONCURRENT, "on_error": OnError.IGNORE} - ) - await plugin_manager.initialize() - result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - assert result.continue_processing - assert not result.modified_payload - await plugin_manager.shutdown() diff --git a/tests/unit/cpex/framework/test_executor_context_concurrency.py b/tests/unit/cpex/framework/test_executor_context_concurrency.py deleted file mode 100644 index 1effa4c7..00000000 --- a/tests/unit/cpex/framework/test_executor_context_concurrency.py +++ /dev/null @@ -1,263 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_executor_context_concurrency.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Concurrency cross-talk tests for the per-call ExecutionContext refactor. - -The PluginExecutor used to keep per-request scratch state on instance attributes -(_max_retry_delay_ms, _hook_chain_executed/skipped/stopped_by/span_id). Because -the executor is held by a Borg-singleton PluginManager and shared across -concurrent dispatches, that state was racy: a second execute() would clobber a -first execute()'s counters and span id. After the refactor, all per-call state -lives on a stack-local ExecutionContext threaded through the call stack, so -concurrent invocations cannot cross-contaminate. -""" - -# Standard -import asyncio -import itertools -import uuid -from typing import Any, Dict, List, Optional, Tuple -from unittest.mock import patch - -# Third-Party -import pytest - -from cpex.framework import ( - GlobalContext, - OnError, - Plugin, - PluginConfig, - PluginManager, - PluginMode, - PluginResult, - PluginViolation, - PromptHookType, - PromptPrehookPayload, -) - -# First-Party -from cpex.framework.base import HookRef -from cpex.framework.observability import current_trace_id -from cpex.framework.registry import PluginRef - - -def _cfg(name: str, mode: PluginMode = PluginMode.SEQUENTIAL) -> PluginConfig: - return PluginConfig( - name=name, - description="test", - author="test", - version="1.0", - kind="test.Plugin", - mode=mode, - on_error=OnError.FAIL, - hooks=["prompt_pre_fetch"], - tags=[], - priority=100, - ) - - -async def _make_manager() -> PluginManager: - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - return manager - - -class _RecordingObservability: - """Captures (start_span, end_span) calls keyed by the span_id we mint.""" - - def __init__(self) -> None: - self._counter = itertools.count() - self.starts: Dict[str, Dict[str, Any]] = {} - self.ends: Dict[str, Tuple[str, Dict[str, Any]]] = {} - - def start_span( - self, - trace_id: str, - name: str, - kind: str = "internal", - resource_type: Optional[str] = None, - resource_name: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - ) -> Optional[str]: - span_id = f"span-{next(self._counter)}" - self.starts[span_id] = { - "trace_id": trace_id, - "name": name, - "attributes": dict(attributes or {}), - } - return span_id - - def end_span( - self, - span_id: Optional[str], - status: str = "ok", - attributes: Optional[Dict[str, Any]] = None, - ) -> None: - if span_id is None: - return - self.ends[span_id] = (status, dict(attributes or {})) - - -@pytest.mark.asyncio -async def test_concurrent_executes_have_isolated_retry_delay(): - """Two concurrent invokes must each return their own plugin's retry_delay_ms. - - Before the refactor, both calls shared self._max_retry_delay_ms; under - interleaving the second call's reset (= 0) or max() update could leak into - the first call's return value. With ExecutionContext per call this is safe. - """ - - barrier = asyncio.Event() - - class DelayPlugin(Plugin): - def __init__(self, cfg: PluginConfig, delay_ms: int) -> None: - super().__init__(cfg) - self._delay = delay_ms - - async def prompt_pre_fetch(self, payload, context): - # Force interleaving: every plugin parks at the barrier before returning - await barrier.wait() - return PluginResult(continue_processing=True, retry_delay_ms=self._delay) - - manager = await _make_manager() - delays = [50, 200, 400, 800, 1600] - plugins = [DelayPlugin(_cfg(f"D{i}"), d) for i, d in enumerate(delays)] - hook_refs = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(p)) for p in plugins] - - async def run_one(hook_ref: HookRef) -> int: - with patch.object(manager._registry, "get_hook_refs_for_hook", return_value=[hook_ref]): - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - PromptPrehookPayload(prompt_id="p", args={}), - GlobalContext(request_id=str(uuid.uuid4())), - ) - return result.retry_delay_ms - - # Schedule all five executes; release them all at once so they interleave. - tasks = [asyncio.create_task(run_one(hr)) for hr in hook_refs] - await asyncio.sleep(0) # let tasks reach the barrier - barrier.set() - returned = await asyncio.gather(*tasks) - - # Each call must report its own delay, in input order. - assert returned == delays - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_concurrent_executes_have_isolated_hook_chain_spans(): - """Each concurrent execute() must produce its own span with counters that - reflect only its own plugins, not the union of overlapping calls.""" - - obs = _RecordingObservability() - manager = await _make_manager() - manager.observability = obs - - barrier = asyncio.Event() - - class StopPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - await barrier.wait() - return PluginResult( - continue_processing=False, - violation=PluginViolation(reason="halt", description="halt", code="HALT", details={}), - ) - - class PassPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - await barrier.wait() - return PluginResult(continue_processing=True) - - halting = StopPlugin(_cfg("Stopper")) - passing = PassPlugin(_cfg("Passer")) - hr_halt = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(halting)) - hr_pass = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(passing)) - - async def run_with_trace(hook_ref: HookRef, trace: str) -> None: - token = current_trace_id.set(trace) - try: - with patch.object(manager._registry, "get_hook_refs_for_hook", return_value=[hook_ref]): - await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - PromptPrehookPayload(prompt_id="p", args={}), - GlobalContext(request_id="r"), - ) - finally: - current_trace_id.reset(token) - - tasks = [ - asyncio.create_task(run_with_trace(hr_halt, "trace-halt")), - asyncio.create_task(run_with_trace(hr_pass, "trace-pass")), - ] - await asyncio.sleep(0) - barrier.set() - await asyncio.gather(*tasks) - - # Each invoke produced its own span. Find them by trace_id. - halt_span = next(sid for sid, info in obs.starts.items() if info["trace_id"] == "trace-halt") - pass_span = next(sid for sid, info in obs.starts.items() if info["trace_id"] == "trace-pass") - assert halt_span != pass_span - - # The halting span attributes attribute the stop to "Stopper" only. - _, halt_attrs = obs.ends[halt_span] - assert halt_attrs["plugin.chain.stopped"] is True - assert halt_attrs["plugin.chain.stopped_by"] == "Stopper" - assert halt_attrs["plugin.executed_count"] == 1 - - # The passing span attributes do NOT carry "Stopper" — it never ran in that chain. - _, pass_attrs = obs.ends[pass_span] - assert pass_attrs["plugin.chain.stopped"] is False - assert pass_attrs["plugin.chain.stopped_by"] == "" - assert pass_attrs["plugin.executed_count"] == 1 - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_many_concurrent_executes_independent_counts(): - """Soak: 50 concurrent invokes, each with two plugins. Every span must - report executed_count=2 — no double-counting, no lost increments.""" - - obs = _RecordingObservability() - manager = await _make_manager() - manager.observability = obs - - class Pass(Plugin): - async def prompt_pre_fetch(self, payload, context): - # tiny await to encourage interleaving across event loop - await asyncio.sleep(0) - return PluginResult(continue_processing=True) - - async def one_invoke(idx: int) -> None: - a = Pass(_cfg(f"A{idx}")) - b = Pass(_cfg(f"B{idx}")) - refs: List[HookRef] = [ - HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(a)), - HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(b)), - ] - token = current_trace_id.set(f"trace-{idx}") - try: - with patch.object(manager._registry, "get_hook_refs_for_hook", return_value=refs): - await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - PromptPrehookPayload(prompt_id="p", args={}), - GlobalContext(request_id=f"r{idx}"), - ) - finally: - current_trace_id.reset(token) - - await asyncio.gather(*(one_invoke(i) for i in range(50))) - - # Filter to hook-chain spans only (per-plugin spans are also recorded but irrelevant here). - chain_span_ids = {sid for sid, info in obs.starts.items() if info["name"] == "plugin.hook.invoke"} - assert len(chain_span_ids) == 50 - for sid in chain_span_ids: - _status, attrs = obs.ends[sid] - assert attrs["plugin.executed_count"] == 2 - assert attrs["plugin.chain.stopped"] is False - assert attrs["plugin.chain.stopped_by"] == "" - - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_extensions_integration.py b/tests/unit/cpex/framework/test_extensions_integration.py deleted file mode 100644 index 9f54efd9..00000000 --- a/tests/unit/cpex/framework/test_extensions_integration.py +++ /dev/null @@ -1,245 +0,0 @@ -# -*- coding: utf-8 -*- -"""Integration tests for the extensions pipeline. - -Covers the end-to-end flow: -1. Plugin receives capability-filtered extensions -2. Plugin modifies allowed slots (labels, custom) -3. Manager merges modifications back with tier validation -4. Immutable/monotonic violations are rejected -""" - -import pytest - -from cpex.framework.extensions.delegation import DelegationExtension, DelegationHop -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.http import HttpExtension -from cpex.framework.extensions.security import ( - SecurityExtension, - SubjectExtension, - SubjectType, -) -from cpex.framework.extensions.tiers import ( - TierViolationError, - filter_extensions, - merge_extensions, -) - - -class TestFilterAndMergeIntegration: - """End-to-end: filter → plugin modifies → merge back.""" - - def _make_extensions(self): - """Create a fully-populated Extensions for testing.""" - return Extensions( - security=SecurityExtension( - labels=frozenset({"internal"}), - subject=SubjectExtension( - id="alice@corp.com", - type=SubjectType.USER, - roles=frozenset({"engineer"}), - permissions=frozenset({"tool_execute"}), - teams=frozenset({"engineering"}), - ), - ), - http=HttpExtension(headers={"authorization": "Bearer tok", "x-request-id": "req-1"}), - delegation=DelegationExtension(), - custom={"trace": True}, - ) - - def test_plugin_with_read_labels_sees_labels(self): - """A plugin with read_labels capability sees security labels.""" - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset({"read_labels"})) - assert filtered.security is not None - assert "internal" in filtered.security.labels - - def test_plugin_without_read_labels_gets_no_labels(self): - """A plugin without read_labels capability does not see labels.""" - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset()) - # Security may still be present (for objects/data) but labels should be empty - if filtered.security: - assert len(filtered.security.labels) == 0 - - def test_plugin_with_read_subject_sees_identity(self): - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset({"read_subject"})) - assert filtered.security is not None - assert filtered.security.subject is not None - assert filtered.security.subject.id == "alice@corp.com" - - def test_plugin_without_read_subject_gets_no_identity(self): - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset()) - if filtered.security and filtered.security.subject: - pytest.fail("Plugin without read_subject should not see subject") - - def test_plugin_with_read_headers_sees_headers(self): - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset({"read_headers"})) - assert filtered.http is not None - assert "authorization" in filtered.http.headers - - def test_plugin_without_read_headers_gets_no_http(self): - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset()) - assert filtered.http is None - - def test_delegation_visible_with_capability(self): - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset({"read_delegation"})) - assert filtered.delegation is not None - - def test_delegation_hidden_without_capability(self): - ext = self._make_extensions() - filtered = filter_extensions(ext, frozenset()) - assert filtered.delegation is None - - def test_merge_accepts_label_addition(self): - """Labels are monotonic — additions are accepted.""" - original = self._make_extensions() - # Simulate plugin adding a label - new_labels = original.security.labels | frozenset({"PII"}) - new_security = original.security.model_copy(update={"labels": new_labels}) - modified = original.model_copy(update={"security": new_security}) - - merged = merge_extensions( - original, - modified, - frozenset({"read_labels", "append_labels"}), - "test-plugin", - ) - assert "PII" in merged.security.labels - assert "internal" in merged.security.labels # original preserved - - def test_merge_rejects_label_removal(self): - """Labels are monotonic — removals are rejected.""" - original = self._make_extensions() - # Simulate plugin removing a label - new_security = original.security.model_copy(update={"labels": frozenset()}) - modified = original.model_copy(update={"security": new_security}) - - with pytest.raises(TierViolationError): - merge_extensions( - original, - modified, - frozenset({"read_labels", "append_labels"}), - "bad-plugin", - ) - - def test_merge_accepts_delegation_chain_growth(self): - """Delegation chain is monotonic — growth is accepted with capability.""" - original = self._make_extensions() - hop = DelegationHop(subject_id="alice", subject_type="user", scopes_granted=("read",)) - new_delegation = original.delegation.with_new_hop(hop) - modified = original.model_copy(update={"delegation": new_delegation}) - - merged = merge_extensions( - original, - modified, - frozenset({"read_delegation", "append_delegation"}), - "test-plugin", - ) - assert merged.delegation.depth == 1 - assert merged.delegation.chain[0].subject_id == "alice" - - def test_merge_ignores_delegation_without_capability(self): - """Delegation changes are ignored without append_delegation capability.""" - original = self._make_extensions() - hop = DelegationHop(subject_id="alice", subject_type="user") - new_delegation = original.delegation.with_new_hop(hop) - modified = original.model_copy(update={"delegation": new_delegation}) - - merged = merge_extensions( - original, - modified, - frozenset(), # no append_delegation - "no-cap-plugin", - ) - # Original delegation preserved — plugin's changes silently dropped - assert merged.delegation.depth == 0 - - def test_merge_rejects_delegation_chain_shrink(self): - """Delegation chain is monotonic — shrinking is rejected.""" - # Build a 2-hop chain - ext = self._make_extensions() - hop1 = DelegationHop(subject_id="alice", subject_type="user") - hop2 = DelegationHop(subject_id="bob", subject_type="agent") - ext1 = ext.model_copy(update={"delegation": ext.delegation.with_new_hop(hop1)}) - ext2 = ext1.model_copy(update={"delegation": ext1.delegation.with_new_hop(hop2)}) - - # Try to shrink back to empty - shrunk = ext2.model_copy(update={"delegation": DelegationExtension()}) - with pytest.raises(TierViolationError): - merge_extensions( - ext2, - shrunk, - frozenset({"read_delegation", "append_delegation"}), - "bad-plugin", - ) - - def test_merge_rejects_delegation_chain_tamper(self): - """Delegation chain is monotonic — modifying existing hops is rejected.""" - ext = self._make_extensions() - hop1 = DelegationHop(subject_id="alice", subject_type="user") - ext1 = ext.model_copy(update={"delegation": ext.delegation.with_new_hop(hop1)}) - - # Tamper with the existing hop - tampered_hop = DelegationHop(subject_id="mallory", subject_type="user") - tampered = ext1.model_copy( - update={ - "delegation": DelegationExtension( - chain=(tampered_hop,), - depth=1, - delegated=True, - origin_subject_id="mallory", - actor_subject_id="mallory", - ) - } - ) - with pytest.raises(TierViolationError): - merge_extensions( - ext1, - tampered, - frozenset({"read_delegation", "append_delegation"}), - "tamper-plugin", - ) - - def test_merge_accepts_custom_changes(self): - """Custom extensions are mutable — any change is accepted.""" - original = self._make_extensions() - modified = original.model_copy(update={"custom": {"trace": False, "new_key": "val"}}) - - merged = merge_extensions(original, modified, frozenset(), "test-plugin") - assert merged.custom["trace"] is False - assert merged.custom["new_key"] == "val" - - def test_merge_accepts_header_changes_with_capability(self): - """HTTP headers are writable with write_headers capability.""" - original = self._make_extensions() - new_http = HttpExtension(headers={"x-request-id": "req-1", "x-custom": "added"}) - modified = original.model_copy(update={"http": new_http}) - - merged = merge_extensions( - original, - modified, - frozenset({"read_headers", "write_headers"}), - "test-plugin", - ) - assert "x-custom" in merged.http.headers - - def test_merge_ignores_header_changes_without_capability(self): - """HTTP headers are NOT writable without write_headers capability.""" - original = self._make_extensions() - new_http = HttpExtension(headers={"x-custom": "sneaky"}) - modified = original.model_copy(update={"http": new_http}) - - merged = merge_extensions( - original, - modified, - frozenset(), # no write_headers - "no-cap-plugin", - ) - # Original headers preserved — plugin's changes ignored - assert "x-custom" not in merged.http.headers - assert "authorization" in merged.http.headers diff --git a/tests/unit/cpex/framework/test_manager.py b/tests/unit/cpex/framework/test_manager.py deleted file mode 100644 index f1e0d7a5..00000000 --- a/tests/unit/cpex/framework/test_manager.py +++ /dev/null @@ -1,556 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_manager.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor, Fred Araujo - -Unit tests for plugin manager. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - GlobalContext, - HttpHeaderPayload, - PluginManager, - PluginViolationError, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ToolHookType, - ToolPostInvokePayload, - ToolPreInvokePayload, -) -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, Role, TextContent -from tests.unit.cpex.fixtures.plugins.search_replace import SearchReplaceConfig - - -@pytest.mark.asyncio -async def test_manager_single_transformer_prompt_plugin(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - await manager.initialize() - assert manager.config.plugins[0].name == "ReplaceBadWordsPlugin" - assert manager.config.plugins[0].kind == "plugins.search_replace.SearchReplacePlugin" - assert manager.config.plugins[0].description == "A plugin for finding and replacing words." - assert manager.config.plugins[0].version == "0.1" - assert manager.config.plugins[0].author == "ContextForge Team" - assert manager.config.plugins[0].hooks[0] == "prompt_pre_fetch" - assert manager.config.plugins[0].hooks[1] == "prompt_post_fetch" - assert manager.config.plugins[0].config - srconfig = SearchReplaceConfig.model_validate(manager.config.plugins[0].config) - assert len(srconfig.words) == 2 - assert srconfig.words[0].search == "crap" - assert srconfig.words[0].replace == "crud" - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "What a crapshow!"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - assert len(result.modified_payload.args) == 1 - assert result.modified_payload.args["user"] == "What a yikesshow!" - - message = Message(content=TextContent(type="text", text=result.modified_payload.args["user"]), role=Role.USER) - - prompt_result = PromptResult(messages=[message]) - - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_POST_FETCH, payload_result, global_context=global_context, local_contexts=contexts - ) - assert len(result.modified_payload.result.messages) == 1 - assert result.modified_payload.result.messages[0].content.text == "What a yikesshow!" - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_multiple_transformer_preprompt_plugin(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml") - await manager.initialize() - assert manager.initialized - assert manager.config.plugins[0].name == "SynonymsPlugin" - assert manager.config.plugins[0].kind == "plugins.search_replace.SearchReplacePlugin" - assert manager.config.plugins[0].description == "A plugin for finding and replacing synonyms." - assert manager.config.plugins[0].version == "0.1" - assert manager.config.plugins[0].author == "ContextForge Team" - assert manager.config.plugins[0].hooks[0] == "prompt_pre_fetch" - assert manager.config.plugins[0].hooks[1] == "prompt_post_fetch" - assert manager.config.plugins[0].config - srconfig = SearchReplaceConfig.model_validate(manager.config.plugins[0].config) - assert len(srconfig.words) == 2 - assert srconfig.words[0].search == "happy" - assert srconfig.words[0].replace == "gleeful" - assert manager.config.plugins[1].name == "ReplaceBadWordsPlugin" - assert manager.config.plugins[1].kind == "plugins.search_replace.SearchReplacePlugin" - assert manager.config.plugins[1].description == "A plugin for finding and replacing words." - assert manager.config.plugins[1].version == "0.1" - assert manager.config.plugins[1].author == "ContextForge Team" - assert manager.config.plugins[1].hooks[0] == "prompt_pre_fetch" - assert manager.config.plugins[1].hooks[1] == "prompt_post_fetch" - assert manager.config.plugins[1].config - srconfig = SearchReplaceConfig.model_validate(manager.config.plugins[1].config) - assert srconfig.words[0].search == "crap" - assert srconfig.words[0].replace == "crud" - assert manager.plugin_count == 2 - - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "It's always happy at the crapshow."}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - assert len(result.modified_payload.args) == 1 - assert result.modified_payload.args["user"] == "It's always gleeful at the yikesshow." - - message = Message(content=TextContent(type="text", text="It's sad at the crud bakery."), role=Role.USER) - - prompt_result = PromptResult(messages=[message]) - - payload_result = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result) - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_POST_FETCH, payload_result, global_context=global_context, local_contexts=contexts - ) - assert len(result.modified_payload.result.messages) == 1 - assert result.modified_payload.result.messages[0].content.text == "It's sullen at the yikes bakery." - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_no_plugins(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - assert manager.initialized - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "It's always happy at the crapshow."}) - global_context = GlobalContext(request_id="1", server_id="2") - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - assert result.continue_processing - assert not result.modified_payload - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_filter_plugins(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_single_filter_plugin.yaml") - await manager.initialize() - assert manager.initialized - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "innovative"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - assert not result.continue_processing - assert result.violation - - with pytest.raises(PluginViolationError) as ve: - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context, violations_as_exceptions=True - ) - assert ve.value.violation - assert ve.value.violation.reason == "Prompt not allowed" - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_multi_filter_plugins(): - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_multiple_plugins_filter.yaml") - await manager.initialize() - assert manager.initialized - prompt = PromptPrehookPayload(prompt_id="test_prompt", args={"user": "innovative crapshow."}) - global_context = GlobalContext(request_id="1", server_id="2") - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - assert not result.continue_processing - assert result.violation - with pytest.raises(PluginViolationError) as ve: - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context, violations_as_exceptions=True - ) - assert ve.value.violation - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_tool_hooks_empty(): - """Test tool hooks with no plugins configured.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke with no plugins - tool_payload = ToolPreInvokePayload(name="calculator", args={"operation": "add", "a": 5, "b": 3}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - # Should continue processing with no modifications - assert result.continue_processing - assert result.modified_payload is None - assert result.violation is None - assert contexts is None - - # Test tool post-invoke with no plugins - tool_result_payload = ToolPostInvokePayload(name="calculator", result={"result": 8, "status": "success"}) - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_result_payload, global_context=global_context - ) - - # Should continue processing with no modifications - assert result.continue_processing - assert result.modified_payload is None - assert result.violation is None - assert contexts is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_tool_hooks_with_transformer_plugin(): - """Test tool hooks with a transformer plugin that doesn't have tool hooks configured.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke - no plugins configured for tool hooks - tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is crap data"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - # Should continue processing with no modifications (no plugins for tool hooks) - assert result.continue_processing - assert result.modified_payload is None # No plugins = no modifications - assert result.violation is None - assert contexts is None - - # Test tool post-invoke - no plugins configured for tool hooks - tool_result_payload = ToolPostInvokePayload(name="test_tool", result={"output": "Result with crap in it"}) - result, _ = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_result_payload, global_context=global_context, local_contexts=contexts - ) - - # Should continue processing with no modifications (no plugins for tool hooks) - assert result.continue_processing - assert result.modified_payload is None # No plugins = no modifications - assert result.violation is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_tool_hooks_with_actual_plugin(): - """Test tool hooks with a real plugin configured for tool processing.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_tool_hooks.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke with transformation - use correct tool name from config - tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.name == "test_tool" - assert result.modified_payload.args["input"] == "This is good data" # bad -> good - assert result.modified_payload.args["quality"] == "right" # wrong -> right - assert result.violation is None - - # Test tool post-invoke with transformation - tool_result_payload = ToolPostInvokePayload( - name="test_tool", result={"output": "Result was bad", "status": "wrong format"} - ) - result, _ = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_result_payload, global_context=global_context, local_contexts=contexts - ) - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.name == "test_tool" - assert result.modified_payload.result["output"] == "Result was good" # bad -> good - assert result.modified_payload.result["status"] == "right format" # wrong -> right - assert result.violation is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_tool_hooks_with_header_mods(): - """Test tool hooks with a real plugin configured for tool processing.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/tool_headers_plugin.yaml") - await manager.initialize() - assert manager.initialized - - # Test tool pre-invoke with transformation - use correct tool name from config - tool_payload = ToolPreInvokePayload( - name="test_tool", args={"input": "This is bad data", "quality": "wrong"}, headers=None - ) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.name == "test_tool" - assert result.modified_payload.args["input"] == "This is bad data" # bad -> good - assert result.modified_payload.args["quality"] == "wrong" # wrong -> right - assert result.violation is None - assert result.modified_payload.headers - assert result.modified_payload.headers["User-Agent"] == "Mozilla/5.0" - assert result.modified_payload.headers["Connection"] == "keep-alive" - - # Test tool pre-invoke with transformation - use correct tool name from config - tool_payload = ToolPreInvokePayload( - name="test_tool", - args={"input": "This is bad data", "quality": "wrong"}, - headers=HttpHeaderPayload({"Content-Type": "application/json"}), - ) - global_context = GlobalContext(request_id="1", server_id="2") - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context - ) - - # Should continue processing with transformations applied - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.name == "test_tool" - assert result.modified_payload.args["input"] == "This is bad data" # bad -> good - assert result.modified_payload.args["quality"] == "wrong" # wrong -> right - assert result.violation is None - assert result.modified_payload.headers - assert result.modified_payload.headers["User-Agent"] == "Mozilla/5.0" - assert result.modified_payload.headers["Connection"] == "keep-alive" - assert result.modified_payload.headers["Content-Type"] == "application/json" - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_plugin_manager_singleton_behavior(): - """Test that PluginManager implements proper singleton pattern (Borg pattern). - - Verifies that: - 1. Multiple instances share the same internal state - 2. Initialization only happens once per process - 3. reset() properly clears the shared state - 4. After reset, a new instance can be initialized with different config - """ - # Clean up any previous state - PluginManager.reset() - - # Create first instance with a specific config - config1_path = "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - manager1 = PluginManager(config1_path) - await manager1.initialize() - - # Verify first instance is initialized - assert manager1.initialized - assert manager1.config is not None - assert manager1.config.plugins[0].name == "ReplaceBadWordsPlugin" - plugin_count_1 = manager1.plugin_count - assert plugin_count_1 > 0 - - # Create second instance with same config - should share state - manager2 = PluginManager(config1_path) - - # Verify both instances share the same state (Borg pattern) - assert manager2.initialized is True, "Second instance should already be initialized" - assert manager2.config is manager1.config, "Both instances should share the same config object" - assert manager2.plugin_count == plugin_count_1, "Both instances should report same plugin count" - assert id(manager1.__dict__) == id(manager2.__dict__), "Both instances should share the same __dict__" - - # Verify that calling initialize again on second instance doesn't re-initialize - await manager2.initialize() - assert manager2.plugin_count == plugin_count_1, "Plugin count should not change on re-initialization" - - # Create third instance with different config path - config2_path = "./tests/unit/cpex/fixtures/configs/valid_multiple_plugins.yaml" - manager3 = PluginManager(config2_path) - - # Verify third instance STILL shares state (config path is ignored after first init) - assert manager3.initialized is True, "Third instance should already be initialized" - assert manager3.config is manager1.config, "Third instance should share config from first instance" - assert manager3.config.plugins[0].name == "ReplaceBadWordsPlugin", "Config should not change" - - # Shutdown the manager - await manager1.shutdown() - - # Now test reset functionality - PluginManager.reset() - - # Verify reset clears the state - manager4 = PluginManager(config2_path) - assert not manager4.initialized, "After reset, new instance should not be initialized" - assert manager4.config is not None, "After reset, config should be loaded from new path" - - # Initialize with the new config - await manager4.initialize() - assert manager4.initialized - assert manager4.config.plugins[0].name == "SynonymsPlugin", "Should have different config after reset" - plugin_count_2 = manager4.plugin_count - assert plugin_count_2 != plugin_count_1, "Plugin count should differ with different config" - - # Create fifth instance - should share new state - manager5 = PluginManager(config1_path) - assert manager5.initialized is True - assert manager5.config is manager4.config, "New instances after reset should share new state" - assert manager5.config.plugins[0].name == "SynonymsPlugin", "Should still have config from after reset" - - # Clean up - await manager4.shutdown() - PluginManager.reset() - - -@pytest.mark.asyncio -async def test_plugin_manager_thread_safety(): - """Test that PluginManager is thread-safe during concurrent initialization. - - Verifies that when multiple threads create PluginManager instances simultaneously, - the config is only loaded once and all instances share the same state. - """ - import threading - import time - - # Clean up any previous state - PluginManager.reset() - - config_path = "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - managers = [] - exceptions = [] - - # Track config loads by wrapping ConfigLoader - from cpex.framework.loader.config import ConfigLoader - - original_load = ConfigLoader.load_config - load_count = {"value": 0} - - def counting_load(*args, **kwargs): - load_count["value"] += 1 - # Add small delay to increase likelihood of race condition - time.sleep(0.01) - return original_load(*args, **kwargs) - - # Monkey-patch the load_config to track calls - ConfigLoader.load_config = staticmethod(counting_load) - - def create_manager(index): - """Thread worker that creates a PluginManager instance.""" - try: - manager = PluginManager(config_path) - managers.append((index, manager)) - except Exception as e: - exceptions.append((index, e)) - - # Create multiple threads that simultaneously create PluginManager instances - num_threads = 10 - threads = [] - - for i in range(num_threads): - thread = threading.Thread(target=create_manager, args=(i,)) - threads.append(thread) - - # Start all threads at approximately the same time - for thread in threads: - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join(timeout=5.0) - - # Restore original load_config - ConfigLoader.load_config = staticmethod(original_load) - - # Verify results - assert len(exceptions) == 0, f"Exceptions occurred during concurrent initialization: {exceptions}" - assert len(managers) == num_threads, f"Expected {num_threads} managers, got {len(managers)}" - - # CRITICAL: Config should only be loaded once despite multiple threads - assert load_count["value"] == 1, ( - f"Config was loaded {load_count['value']} times instead of 1 (race condition detected)" - ) - - # Verify all managers share the same state - first_manager = managers[0][1] - for i, manager in managers: - assert manager.config is first_manager.config, f"Manager {i} has different config object" - assert id(manager.__dict__) == id(first_manager.__dict__), f"Manager {i} has different __dict__" - assert manager.config.plugins[0].name == "ReplaceBadWordsPlugin" - - # Initialize one of them and verify all show initialized - await first_manager.initialize() - for i, manager in managers: - assert manager.initialized, f"Manager {i} not showing as initialized" - - # Clean up - await first_manager.shutdown() - PluginManager.reset() - - -@pytest.mark.asyncio -async def test_plugin_manager_async_concurrency(): - """Test that PluginManager handles concurrent async initialization and shutdown. - - Verifies that when multiple coroutines try to initialize/shutdown simultaneously, - the asyncio.Lock prevents race conditions and ensures proper state management. - """ - import asyncio - - # Clean up any previous state - PluginManager.reset() - - config_path = "./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml" - - # Test 1: Concurrent initializations - managers = [PluginManager(config_path) for _ in range(5)] - - # Try to initialize all managers concurrently - await asyncio.gather(*[m.initialize() for m in managers]) - - # All should be initialized and share the same state - for manager in managers: - assert manager.initialized - assert manager.plugin_count == 1 - assert manager.config.plugins[0].name == "ReplaceBadWordsPlugin" - - # Test 2: Concurrent initialize + shutdown (should be serialized by lock) - manager1 = managers[0] - await manager1.shutdown() - - # Reset for second test - PluginManager.reset() - manager2 = PluginManager(config_path) - - # Create tasks that will race - async def init_task(): - await asyncio.sleep(0.01) # Small delay - await manager2.initialize() - return "init" - - async def shutdown_task(): - await asyncio.sleep(0.01) # Small delay - await manager2.shutdown() - return "shutdown" - - # Run init and shutdown concurrently (lock should serialize them) - results = await asyncio.gather(init_task(), shutdown_task()) - - # One should succeed, the other should be no-op - # The key is that no exception should occur due to race condition - assert len(results) == 2 - - # Test 3: Multiple shutdowns (should be idempotent) - PluginManager.reset() - manager3 = PluginManager(config_path) - await manager3.initialize() - assert manager3.initialized - - # Shutdown multiple times concurrently - await asyncio.gather(*[manager3.shutdown() for _ in range(5)]) - - # Should be cleanly shutdown - assert not manager3.initialized - - # Clean up - PluginManager.reset() diff --git a/tests/unit/cpex/framework/test_manager_coverage.py b/tests/unit/cpex/framework/test_manager_coverage.py deleted file mode 100644 index 4dc7b1ac..00000000 --- a/tests/unit/cpex/framework/test_manager_coverage.py +++ /dev/null @@ -1,672 +0,0 @@ -# -*- coding: utf-8 -*- -"""Coverage tests for cpex.framework.manager — invoke_hook_for_plugin, _execute_with_timeout, audit mode.""" - -# Standard -import asyncio -from unittest.mock import MagicMock - -# Third-Party -import pytest - -# First-Party -from cpex.framework.base import HookRef, Plugin, PluginRef -from cpex.framework.errors import PluginError -from cpex.framework.manager import PluginExecutor, PluginManager -from cpex.framework.models import ( - GlobalContext, - PluginConfig, - PluginContext, - PluginMode, - PluginPayload, - PluginResult, - PluginViolation, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_config(name="test", priority=100, mode=PluginMode.TRANSFORM, hooks=None): - return PluginConfig( - name=name, - kind="test.Plugin", - version="1.0", - hooks=hooks or ["test_hook"], - mode=mode, - priority=priority, - ) - - -class ConcretePlugin(Plugin): - async def test_hook(self, payload: PluginPayload, context: PluginContext) -> PluginResult: - return PluginResult(continue_processing=True) - - -def _make_hook_ref(plugin=None, mode=PluginMode.TRANSFORM): - plugin = plugin or ConcretePlugin(_make_config(mode=mode)) - ref = PluginRef(plugin) - return HookRef("test_hook", ref) - - -# =========================================================================== -# invoke_hook_for_plugin -# =========================================================================== - - -class TestInvokeHookForPlugin: - @pytest.fixture(autouse=True) - def reset_manager(self): - PluginManager.reset() - yield - PluginManager.reset() - - @pytest.mark.asyncio - async def test_success(self): - manager = PluginManager() - manager._initialized = True - hook_ref = _make_hook_ref() - - manager._registry = MagicMock() - manager._registry.get_plugin_hook_by_name.return_value = hook_ref - - payload = MagicMock(spec=PluginPayload) - context = PluginContext(global_context=GlobalContext(request_id="1")) - - result = await manager.invoke_hook_for_plugin("test", "test_hook", payload, context) - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_not_found_raises(self): - manager = PluginManager() - manager._initialized = True - manager._registry = MagicMock() - manager._registry.get_plugin_hook_by_name.return_value = None - - payload = MagicMock(spec=PluginPayload) - context = PluginContext(global_context=GlobalContext(request_id="1")) - - with pytest.raises(PluginError, match="Unable to find"): - await manager.invoke_hook_for_plugin("missing", "test_hook", payload, context) - - @pytest.mark.asyncio - async def test_json_payload_dict(self): - manager = PluginManager() - manager._initialized = True - - plugin = ConcretePlugin(_make_config()) - plugin.json_to_payload = MagicMock(return_value=MagicMock(spec=PluginPayload)) - hook_ref = _make_hook_ref(plugin) - - manager._registry = MagicMock() - manager._registry.get_plugin_hook_by_name.return_value = hook_ref - - context = PluginContext(global_context=GlobalContext(request_id="1")) - - result = await manager.invoke_hook_for_plugin( - "test", "test_hook", {"key": "val"}, context, payload_as_json=True - ) - plugin.json_to_payload.assert_called_once_with("test_hook", {"key": "val"}) - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_json_payload_wrong_type_raises(self): - manager = PluginManager() - manager._initialized = True - - hook_ref = _make_hook_ref() - manager._registry = MagicMock() - manager._registry.get_plugin_hook_by_name.return_value = hook_ref - - context = PluginContext(global_context=GlobalContext(request_id="1")) - - with pytest.raises(ValueError, match="must be str or dict"): - await manager.invoke_hook_for_plugin("test", "test_hook", 12345, context, payload_as_json=True) - - @pytest.mark.asyncio - async def test_wrong_payload_type_raises(self): - manager = PluginManager() - manager._initialized = True - - hook_ref = _make_hook_ref() - manager._registry = MagicMock() - manager._registry.get_plugin_hook_by_name.return_value = hook_ref - - context = PluginContext(global_context=GlobalContext(request_id="1")) - - with pytest.raises(ValueError, match="must be a PluginPayload"): - await manager.invoke_hook_for_plugin("test", "test_hook", "not-a-payload", context, payload_as_json=False) - - @pytest.mark.asyncio - async def test_global_context_auto_wrap(self): - manager = PluginManager() - manager._initialized = True - hook_ref = _make_hook_ref() - - manager._registry = MagicMock() - manager._registry.get_plugin_hook_by_name.return_value = hook_ref - - payload = MagicMock(spec=PluginPayload) - global_context = GlobalContext(request_id="1") - - result = await manager.invoke_hook_for_plugin("test", "test_hook", payload, global_context) - assert result.continue_processing is True - - -# =========================================================================== -# _execute_with_timeout observability -# =========================================================================== - - -class TestExecuteWithTimeout: - @pytest.mark.asyncio - async def test_with_trace_id(self): - from cpex.framework.observability import current_trace_id - - mock_provider = MagicMock() - mock_provider.start_span.return_value = "span-123" - - executor = PluginExecutor(timeout=30, observability=mock_provider) - hook_ref = _make_hook_ref() - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - token = current_trace_id.set("trace-abc") - try: - result = await executor._execute_with_timeout(hook_ref, payload, context) - finally: - current_trace_id.reset(token) - - assert result.continue_processing is True - mock_provider.start_span.assert_called_once() - mock_provider.end_span.assert_called_once() - - @pytest.mark.asyncio - async def test_no_trace(self): - mock_provider = MagicMock() - - executor = PluginExecutor(timeout=30, observability=mock_provider) - hook_ref = _make_hook_ref() - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - # current_trace_id defaults to None, so no tracing should occur - result = await executor._execute_with_timeout(hook_ref, payload, context) - - assert result.continue_processing is True - mock_provider.start_span.assert_not_called() - mock_provider.end_span.assert_not_called() - - @pytest.mark.asyncio - async def test_observability_provider_failure(self): - from cpex.framework.observability import current_trace_id - - mock_provider = MagicMock() - mock_provider.start_span.side_effect = Exception("provider fail") - - executor = PluginExecutor(timeout=30, observability=mock_provider) - hook_ref = _make_hook_ref() - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - token = current_trace_id.set("trace-abc") - try: - result = await executor._execute_with_timeout(hook_ref, payload, context) - finally: - current_trace_id.reset(token) - - # Should still succeed despite provider failure - assert result.continue_processing is True - - @pytest.mark.asyncio - async def test_error_path_ends_span_with_error(self): - """When plugin execution raises, end_span is called with status='error'.""" - from cpex.framework.observability import current_trace_id - - mock_provider = MagicMock() - mock_provider.start_span.return_value = "span-err" - - class FailingPlugin(Plugin): - async def test_hook(self, payload, context): - raise RuntimeError("boom") - - plugin = FailingPlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - executor = PluginExecutor(timeout=30, observability=mock_provider) - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - token = current_trace_id.set("trace-err") - try: - with pytest.raises(RuntimeError, match="boom"): - await executor._execute_with_timeout(hook_ref, payload, context) - finally: - current_trace_id.reset(token) - - mock_provider.start_span.assert_called_once() - mock_provider.end_span.assert_called_once_with(span_id="span-err", status="error") - - @pytest.mark.asyncio - async def test_error_path_end_span_also_fails(self): - """When plugin raises AND end_span also raises, the original error propagates.""" - from cpex.framework.observability import current_trace_id - - mock_provider = MagicMock() - mock_provider.start_span.return_value = "span-double-err" - mock_provider.end_span.side_effect = Exception("end_span also broke") - - class FailingPlugin(Plugin): - async def test_hook(self, payload, context): - raise RuntimeError("plugin boom") - - plugin = FailingPlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - executor = PluginExecutor(timeout=30, observability=mock_provider) - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - token = current_trace_id.set("trace-double-err") - try: - with pytest.raises(RuntimeError, match="plugin boom"): - await executor._execute_with_timeout(hook_ref, payload, context) - finally: - current_trace_id.reset(token) - - # end_span was attempted despite the error - mock_provider.end_span.assert_called_once_with(span_id="span-double-err", status="error") - - @pytest.mark.asyncio - async def test_end_span_failure_on_success_path(self): - """When end_span raises after successful execution, the result is still returned.""" - from cpex.framework.observability import current_trace_id - - mock_provider = MagicMock() - mock_provider.start_span.return_value = "span-ok" - mock_provider.end_span.side_effect = Exception("end_span broke") - - executor = PluginExecutor(timeout=30, observability=mock_provider) - hook_ref = _make_hook_ref() - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - token = current_trace_id.set("trace-ok") - try: - result = await executor._execute_with_timeout(hook_ref, payload, context) - finally: - current_trace_id.reset(token) - - # Plugin result is returned despite end_span failure - assert result.continue_processing is True - mock_provider.start_span.assert_called_once() - mock_provider.end_span.assert_called_once() - - -# =========================================================================== -# Audit mode with no violation -# =========================================================================== - - -class TestAuditBlocking: - @pytest.mark.asyncio - async def test_audit_no_violation(self): - """Plugin returns continue_processing=False in audit mode with no violation object.""" - plugin = ConcretePlugin(_make_config(mode=PluginMode.AUDIT)) - - # Override to return blocking result with no violation - async def blocking_hook(payload, context): - return PluginResult(continue_processing=False, violation=None) - - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - hook_ref._func = blocking_hook - - executor = PluginExecutor(timeout=30) - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - result = await executor.execute_plugin(hook_ref, payload, context, False) - # In audit mode, should still return the result (just log warning) - assert result.continue_processing - - @pytest.mark.asyncio - async def test_audit_with_violation_description(self): - """Plugin returns violation with description in audit mode.""" - plugin = ConcretePlugin(_make_config(mode=PluginMode.AUDIT)) - - async def blocking_hook(payload, context): - return PluginResult( - continue_processing=False, - violation=PluginViolation(reason="test", description="detailed", code="V1"), - ) - - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - hook_ref._func = blocking_hook - - executor = PluginExecutor(timeout=30) - context = PluginContext(global_context=GlobalContext(request_id="1")) - payload = MagicMock(spec=PluginPayload) - - result = await executor.execute_plugin(hook_ref, payload, context, False) - assert result.continue_processing - assert not result.violation - - -# =========================================================================== -# Cross-type payload: unexpected type warning (manager.py line 251) -# =========================================================================== - - -class TestCrossTypeUnexpectedPayload: - """When a plugin returns a modified_payload of an unexpected type (not - PluginPayload or dict) under an explicit policy, the modification is - silently ignored with a warning.""" - - @pytest.mark.asyncio - async def test_unexpected_type_ignored_with_policy(self): - from cpex.framework.hooks.policies import HookPayloadPolicy - - class WeirdResultPlugin(Plugin): - async def test_hook(self, payload, context): - # Return an unexpected type (list) as modified_payload - return PluginResult(continue_processing=True, modified_payload=["unexpected", "list"]) - - config = _make_config(name="weird") - plugin = WeirdResultPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = PluginPayload() - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="test_hook", - ) - # The unexpected type should be ignored — modified_payload stays None - assert result.modified_payload is None - - -# =========================================================================== -# PluginManager Borg: hook_policies injection paths (lines 581-596) -# =========================================================================== - - -class TestBorgHookPoliciesInjection: - @pytest.fixture(autouse=True) - def reset_manager(self): - PluginManager.reset() - yield - PluginManager.reset() - - def test_second_instantiation_injects_policies(self): - """When the first PluginManager had no policies but a second one - provides them, the policies are injected into the shared executor.""" - from cpex.framework.hooks.policies import HookPayloadPolicy - - # First instantiation — no policies - pm1 = PluginManager() - assert pm1._executor is not None - assert not pm1._executor.hook_policies - - # Second instantiation — provides policies - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - pm2 = PluginManager(hook_policies=policies) - - # Borg pattern: both share state - assert pm1._executor.hook_policies == policies - assert pm2._executor.hook_policies == policies - - def test_second_instantiation_updates_timeout(self): - """When the second instantiation provides a non-default timeout, - it updates the shared executor's timeout.""" - from cpex.framework.hooks.policies import HookPayloadPolicy - - pm1 = PluginManager() - _ = pm1._executor.timeout - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset())} - pm2 = PluginManager(timeout=120, hook_policies=policies) - - assert pm2._executor.timeout == 120 - - def test_second_instantiation_warns_on_different_policies(self, caplog): - """When policies are already set and a different set is provided, - a warning is logged and the new policies are ignored.""" - from cpex.framework.hooks.policies import HookPayloadPolicy - - policies_a = {"hook_a": HookPayloadPolicy(writable_fields=frozenset({"x"}))} - policies_b = {"hook_b": HookPayloadPolicy(writable_fields=frozenset({"y"}))} - - _ = PluginManager(hook_policies=policies_a) - pm2 = PluginManager(hook_policies=policies_b) - - assert "already set" in caplog.text - # Original policies are retained - assert pm2._executor.hook_policies == policies_a - - def test_second_instantiation_injects_observability(self): - """When observability is not yet set and a second instantiation - provides it, the executor is updated.""" - from cpex.framework.hooks.policies import HookPayloadPolicy - - pm1 = PluginManager() - assert pm1._executor.observability is None - - mock_obs = MagicMock() - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset())} - pm2 = PluginManager(hook_policies=policies, observability=mock_obs) - - assert pm2._executor.observability is mock_obs - - -# =========================================================================== -# PluginManager executor property and setter (lines 605, 615, 620) -# =========================================================================== - - -class TestExecutorPropertySetter: - @pytest.fixture(autouse=True) - def reset_manager(self): - PluginManager.reset() - yield - PluginManager.reset() - - def test_executor_property_returns_executor(self): - pm = PluginManager() - executor = pm.executor - assert isinstance(executor, PluginExecutor) - - def test_executor_property_lazy_creates(self): - """When _executor is None, the property lazily creates one.""" - pm = PluginManager() - pm._executor = None - executor = pm.executor - assert isinstance(executor, PluginExecutor) - - def test_executor_setter(self): - pm = PluginManager() - new_executor = PluginExecutor() - pm.executor = new_executor - assert pm._executor is new_executor - - -# =========================================================================== -# PluginManager.shutdown lazy async_lock (line 810) -# =========================================================================== - - -class TestShutdownLazyAsyncLock: - @pytest.fixture(autouse=True) - def reset_manager(self): - PluginManager.reset() - yield - PluginManager.reset() - - @pytest.mark.asyncio - async def test_shutdown_creates_async_lock_lazily(self): - """shutdown() should lazily create _async_lock if it is None.""" - pm = PluginManager() - # Ensure _async_lock starts as None (fresh Borg state) - assert pm._async_lock is None - - # shutdown on uninitialized manager should still create the lock - await pm.shutdown() - - assert pm._async_lock is not None - assert isinstance(pm._async_lock, asyncio.Lock) - - -# =========================================================================== -# Copy-on-Write payload isolation -# =========================================================================== - - -class TestCopyOnWritePayloadIsolation: - """Verify that CoW-based isolation protects the live payload chain.""" - - @pytest.fixture(autouse=True) - def reset_manager(self): - PluginManager.reset() - yield - PluginManager.reset() - - @pytest.mark.asyncio - async def test_inplace_args_mutation_does_not_corrupt_chain(self): - """Plugin that mutates payload.args[k] in-place should not affect - the effective_payload seen by subsequent plugins.""" - from cpex.framework.hooks.policies import HookPayloadPolicy - from cpex.framework.hooks.tools import ToolPreInvokePayload - - mutations_seen = [] - - class MutatingPlugin(Plugin): - async def test_hook(self, payload, context): - # In-place mutation of the wrapped args dict - payload.args["injected"] = "evil" - mutations_seen.append(dict(payload.args)) - return PluginResult(continue_processing=True) - - class ObservingPlugin(Plugin): - async def test_hook(self, payload, context): - mutations_seen.append(dict(payload.args)) - return PluginResult(continue_processing=True) - - config_m = _make_config(name="mutator", priority=1) - config_o = _make_config(name="observer", priority=2) - plugin_m = MutatingPlugin(config_m) - plugin_o = ObservingPlugin(config_o) - - hr_m = HookRef("test_hook", PluginRef(plugin_m)) - hr_o = HookRef("test_hook", PluginRef(plugin_o)) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"args"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = ToolPreInvokePayload(name="calc", args={"x": "1"}) - ctx = GlobalContext(request_id="cow-test") - - await executor.execute([hr_m, hr_o], payload, ctx, hook_type="test_hook") - - # Mutator sees its own mutation - assert mutations_seen[0] == {"x": "1", "injected": "evil"} - # Observer should see the original args, not the mutation - assert "injected" not in mutations_seen[1] - assert mutations_seen[1] == {"x": "1"} - - @pytest.mark.asyncio - async def test_http_header_setitem_goes_to_cow(self): - """HttpHeaderPayload.__setitem__ writes go to CoW overlay, - not the original header dict.""" - from cpex.framework.hooks.http import HttpHeaderPayload - from cpex.framework.hooks.policies import HookPayloadPolicy - - original_headers = {"Authorization": "Bearer token"} - - class HeaderMutator(Plugin): - async def test_hook(self, payload, context): - payload.root["X-Injected"] = "bad" - return PluginResult(continue_processing=True) - - config = _make_config(name="hdr_mutator", priority=1) - plugin = HeaderMutator(config) - hr = HookRef("test_hook", PluginRef(plugin)) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"root"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = HttpHeaderPayload(root=original_headers) - ctx = GlobalContext(request_id="hdr-cow-test") - - await executor.execute([hr], payload, ctx, hook_type="test_hook") - - # Original headers untouched - assert "X-Injected" not in original_headers - assert original_headers == {"Authorization": "Bearer token"} - - @pytest.mark.asyncio - async def test_policy_filtering_works_with_cow(self): - """Policy-based field filtering works correctly when the input - was CoW-wrapped (plugin returns a new payload via model_copy).""" - from cpex.framework.hooks.policies import HookPayloadPolicy - from cpex.framework.hooks.tools import ToolPreInvokePayload - - class PolicyPlugin(Plugin): - async def test_hook(self, payload, context): - new = payload.model_copy(update={"name": "renamed"}) - return PluginResult(continue_processing=True, modified_payload=new) - - config = _make_config(name="policied") - plugin = PolicyPlugin(config) - hr = HookRef("test_hook", PluginRef(plugin)) - - # Only "name" is writable - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = ToolPreInvokePayload(name="original", args={"a": "1"}) - ctx = GlobalContext(request_id="policy-cow-test") - - result, _ = await executor.execute([hr], payload, ctx, hook_type="test_hook") - - assert result.modified_payload is not None - assert result.modified_payload.name == "renamed" - assert result.modified_payload.args == {"a": "1"} - - @pytest.mark.asyncio - async def test_deny_by_default_uses_cow_isolation(self): - """When default_hook_policy=DENY, payload is CoW-isolated even - without an explicit per-hook policy.""" - from cpex.framework.hooks.policies import DefaultHookPolicy - from cpex.framework.hooks.tools import ToolPreInvokePayload - from cpex.framework.memory import CopyOnWriteDict - - saw_cow = [] - - class InspectPlugin(Plugin): - async def test_hook(self, payload, context): - saw_cow.append(isinstance(payload.args, CopyOnWriteDict)) - return PluginResult(continue_processing=True) - - config = _make_config(name="inspector") - plugin = InspectPlugin(config) - hr = HookRef("test_hook", PluginRef(plugin)) - - executor = PluginExecutor() - executor.default_hook_policy = DefaultHookPolicy.DENY - - payload = ToolPreInvokePayload(name="t", args={"k": "v"}) - ctx = GlobalContext(request_id="deny-cow-test") - - await executor.execute([hr], payload, ctx, hook_type="test_hook") - - assert saw_cow == [True] diff --git a/tests/unit/cpex/framework/test_manager_extended.py b/tests/unit/cpex/framework/test_manager_extended.py deleted file mode 100644 index 0cd5610b..00000000 --- a/tests/unit/cpex/framework/test_manager_extended.py +++ /dev/null @@ -1,1184 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_manager_extended.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Extended tests for plugin manager to achieve 100% coverage. -""" - -# Standard -import asyncio -import importlib.util -import re -import sys -import uuid -from pathlib import Path -from unittest.mock import patch - -# Third-Party -import pytest - -from cpex.framework import ( - GlobalContext, - OnError, - Plugin, - PluginCondition, - PluginConfig, - PluginContext, - PluginError, - PluginManager, - PluginMode, - PluginResult, - PluginViolation, - PluginViolationError, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ToolHookType, - ToolPostInvokePayload, - ToolPreInvokePayload, -) - -# First-Party -from cpex.framework.base import HookRef -from cpex.framework.models import Config -from cpex.framework.registry import PluginRef -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, Role, TextContent - - -def test_manager_module_import_does_not_parse_plugin_settings(monkeypatch): - """Importing manager.py must not eagerly parse full PluginsSettings. - - With PLUGINS_SERVER_PORT set to a non-integer, any attempt to - instantiate the full PluginsSettings model would raise a - ValidationError. If exec_module succeeds, settings were deferred. - """ - monkeypatch.setenv("PLUGINS_SERVER_PORT", "abc") - - module_name = f"cpex._plugin_manager_test_{uuid.uuid4().hex}" - spec = importlib.util.spec_from_file_location(module_name, Path("cpex/framework/manager.py")) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - monkeypatch.setitem(sys.modules, module_name, module) - # If this raises ValidationError, settings are being eagerly parsed at import time - spec.loader.exec_module(module) - - assert hasattr(module, "PluginManager") - - -@pytest.mark.asyncio -async def test_manager_timeout_handling(): - """Test plugin timeout handling in both concurrent and audit modes.""" - - # Create a plugin that times out - class TimeoutPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - await asyncio.sleep(10) # Longer than timeout - return PluginResult(continue_processing=True) - - # Test with concurrent mode - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - manager._executor.timeout = 0.01 # Set very short timeout - - # Mock plugin registry - plugin_config = PluginConfig( - name="TimeoutPlugin", - description="Test timeout plugin", - author="Test", - version="1.0", - tags=["test"], - kind="TimeoutPlugin", - mode=PluginMode.CONCURRENT, - hooks=["prompt_pre_fetch"], - config={}, - ) - timeout_plugin = TimeoutPlugin(plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(timeout_plugin)) - mock_get.return_value = [hook_ref] - - prompt = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="1") - - escaped_regex = re.escape("Plugin TimeoutPlugin exceeded 0.01s timeout") - with pytest.raises(PluginError, match=escaped_regex): - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context - ) - - # Should pass since fail_on_plugin_error: false - # assert result.continue_processing - # assert result.violation is not None - # assert result.violation.code == "PLUGIN_TIMEOUT" - # assert "timeout" in result.violation.description.lower() - - # Test with audit mode + on_error=IGNORE (errors are logged and ignored) - audit_config = plugin_config.model_copy(update={"mode": PluginMode.AUDIT, "on_error": OnError.IGNORE}) - audit_plugin = TimeoutPlugin(audit_config) - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(audit_plugin)) - mock_get.return_value = [hook_ref] - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Should continue in audit mode with on_error=IGNORE - assert result.continue_processing - assert result.violation is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_exception_handling(): - """Test plugin exception handling in both concurrent and audit modes.""" - - # Create a plugin that raises an exception - class ErrorPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - raise RuntimeError("Plugin error!") - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - plugin_config = PluginConfig( - name="ErrorPlugin", - description="Test error plugin", - author="Test", - version="1.0", - tags=["test"], - kind="ErrorPlugin", - mode=PluginMode.CONCURRENT, - hooks=["prompt_pre_fetch"], - config={}, - ) - error_plugin = ErrorPlugin(plugin_config) - - # Test with concurrent mode - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(error_plugin)) - mock_get.return_value = [hook_ref] - - prompt = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="1") - - escaped_regex = re.escape("RuntimeError('Plugin error!')") - with pytest.raises(PluginError, match=escaped_regex): - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context - ) - - # Should block in concurrent mode - # assert result.continue_processing - # assert result.violation is not None - # assert result.violation.code == "PLUGIN_ERROR" - # assert "error" in result.violation.description.lower() - - # Test with audit mode + on_error=IGNORE (errors are logged and ignored) - audit_config = plugin_config.model_copy(update={"mode": PluginMode.AUDIT, "on_error": OnError.IGNORE}) - audit_plugin = ErrorPlugin(audit_config) - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(audit_plugin)) - mock_get.return_value = [hook_ref] - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Should continue in audit mode with on_error=IGNORE - assert result.continue_processing - assert result.violation is None - - # Test with concurrent mode + on_error=IGNORE (repeated to verify consistency) - ignore_config = plugin_config.model_copy(update={"mode": PluginMode.CONCURRENT, "on_error": OnError.IGNORE}) - ignore_plugin = ErrorPlugin(ignore_config) - for _ in range(3): - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(ignore_plugin)) - mock_get.return_value = [hook_ref] - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context - ) - - # Should continue with on_error=ignore - assert result.continue_processing - assert result.violation is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_condition_filtering(): - """Test that plugins are filtered based on conditions across all hook types.""" - from cpex.framework import ( - AgentHookType, - AgentPreInvokePayload, - ResourceHookType, - ResourcePreFetchPayload, - ) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - # ========== Test 1: Server ID condition (GlobalContext) ========== - class ConditionalPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - payload.args["modified"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - plugin_config = PluginConfig( - name="ConditionalPlugin", - description="Test conditional plugin", - author="Test", - version="1.0", - tags=["test"], - kind="ConditionalPlugin", - hooks=["prompt_pre_fetch"], - config={}, - conditions=[PluginCondition(server_ids={"server1"})], - ) - plugin = ConditionalPlugin(plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - plugin_ref = PluginRef(plugin) - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, plugin_ref) - mock_get.return_value = [hook_ref] - - prompt = PromptPrehookPayload(prompt_id="test", args={}) - - # Test with matching server_id - global_context = GlobalContext(request_id="1", server_id="server1") - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Plugin should execute - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.args.get("modified") == "yes" - - # Test with non-matching server_id - prompt2 = PromptPrehookPayload(prompt_id="test", args={}) - global_context2 = GlobalContext(request_id="2", server_id="server2") - result2, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt2, global_context=global_context2) - - # Plugin should be skipped - assert result2.continue_processing - assert result2.modified_payload is None # No modification - - # ========== Test 2: Prompt-specific filtering ========== - class PromptFilterPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - payload.args["prompt_filtered"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - prompt_plugin_config = PluginConfig( - name="PromptFilterPlugin", - description="Test prompt filtering", - author="Test", - version="1.0", - tags=["test"], - kind="PromptFilterPlugin", - hooks=["prompt_pre_fetch"], - config={}, - conditions=[PluginCondition(prompts={"greeting", "welcome"})], - ) - prompt_plugin = PromptFilterPlugin(prompt_plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(prompt_plugin)) - mock_get.return_value = [hook_ref] - - # Test with matching prompt - prompt_match = PromptPrehookPayload(prompt_id="greeting", args={}) - global_context = GlobalContext(request_id="3") - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt_match, global_context=global_context - ) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.args.get("prompt_filtered") == "yes" - - # Test with non-matching prompt - prompt_no_match = PromptPrehookPayload(prompt_id="other", args={}) - result2, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt_no_match, global_context=global_context - ) - - assert result2.continue_processing - assert result2.modified_payload is None # Plugin skipped - - # ========== Test 3: Tool filtering ========== - class ToolFilterPlugin(Plugin): - async def tool_pre_invoke(self, payload, context): - payload.args["tool_filtered"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - tool_plugin_config = PluginConfig( - name="ToolFilterPlugin", - description="Test tool filtering", - author="Test", - version="1.0", - tags=["test"], - kind="ToolFilterPlugin", - hooks=["tool_pre_invoke"], - config={}, - conditions=[PluginCondition(tools={"calculator", "converter"})], - ) - tool_plugin = ToolFilterPlugin(tool_plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(ToolHookType.TOOL_PRE_INVOKE, PluginRef(tool_plugin)) - mock_get.return_value = [hook_ref] - - # Test with matching tool - tool_match = ToolPreInvokePayload(name="calculator", args={}) - global_context = GlobalContext(request_id="4") - result, _ = await manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, tool_match, global_context=global_context) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.args.get("tool_filtered") == "yes" - - # Test with non-matching tool - tool_no_match = ToolPreInvokePayload(name="other_tool", args={}) - result2, _ = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_no_match, global_context=global_context - ) - - assert result2.continue_processing - assert result2.modified_payload is None # Plugin skipped - - # ========== Test 4: Resource filtering ========== - class ResourceFilterPlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - payload.metadata["resource_filtered"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - resource_plugin_config = PluginConfig( - name="ResourceFilterPlugin", - description="Test resource filtering", - author="Test", - version="1.0", - tags=["test"], - kind="ResourceFilterPlugin", - hooks=["resource_pre_fetch"], - config={}, - conditions=[PluginCondition(resources={"file:///data.txt", "file:///config.json"})], - ) - resource_plugin = ResourceFilterPlugin(resource_plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(ResourceHookType.RESOURCE_PRE_FETCH, PluginRef(resource_plugin)) - mock_get.return_value = [hook_ref] - - # Test with matching resource - resource_match = ResourcePreFetchPayload(uri="file:///data.txt", metadata={}) - global_context = GlobalContext(request_id="5") - result, _ = await manager.invoke_hook( - ResourceHookType.RESOURCE_PRE_FETCH, resource_match, global_context=global_context - ) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.metadata.get("resource_filtered") == "yes" - - # Test with non-matching resource - resource_no_match = ResourcePreFetchPayload(uri="file:///other.txt", metadata={}) - result2, _ = await manager.invoke_hook( - ResourceHookType.RESOURCE_PRE_FETCH, resource_no_match, global_context=global_context - ) - - assert result2.continue_processing - assert result2.modified_payload is None # Plugin skipped - - # ========== Test 5: Agent filtering ========== - class AgentFilterPlugin(Plugin): - async def agent_pre_invoke(self, payload, context): - payload.parameters["agent_filtered"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - agent_plugin_config = PluginConfig( - name="AgentFilterPlugin", - description="Test agent filtering", - author="Test", - version="1.0", - tags=["test"], - kind="AgentFilterPlugin", - hooks=["agent_pre_invoke"], - config={}, - conditions=[PluginCondition(agents={"agent1", "agent2"})], - ) - agent_plugin = AgentFilterPlugin(agent_plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(AgentHookType.AGENT_PRE_INVOKE, PluginRef(agent_plugin)) - mock_get.return_value = [hook_ref] - - # Test with matching agent - agent_match = AgentPreInvokePayload(agent_id="agent1", messages=[], parameters={}) - global_context = GlobalContext(request_id="6") - result, _ = await manager.invoke_hook( - AgentHookType.AGENT_PRE_INVOKE, agent_match, global_context=global_context - ) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.parameters.get("agent_filtered") == "yes" - - # Test with non-matching agent - agent_no_match = AgentPreInvokePayload(agent_id="agent3", messages=[], parameters={}) - result2, _ = await manager.invoke_hook( - AgentHookType.AGENT_PRE_INVOKE, agent_no_match, global_context=global_context - ) - - assert result2.continue_processing - assert result2.modified_payload is None # Plugin skipped - - # ========== Test 6: Combined conditions (server_id + tool name) ========== - class CombinedFilterPlugin(Plugin): - async def tool_pre_invoke(self, payload, context): - payload.args["combined_filtered"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - combined_plugin_config = PluginConfig( - name="CombinedFilterPlugin", - description="Test combined filtering", - author="Test", - version="1.0", - tags=["test"], - kind="CombinedFilterPlugin", - hooks=["tool_pre_invoke"], - config={}, - conditions=[PluginCondition(server_ids={"server1"}, tools={"calculator"})], - ) - combined_plugin = CombinedFilterPlugin(combined_plugin_config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(ToolHookType.TOOL_PRE_INVOKE, PluginRef(combined_plugin)) - mock_get.return_value = [hook_ref] - - # Test with both conditions matching - tool_payload = ToolPreInvokePayload(name="calculator", args={}) - global_context = GlobalContext(request_id="7", server_id="server1") - result, _ = await manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.args.get("combined_filtered") == "yes" - - # Test with server_id mismatch - global_context2 = GlobalContext(request_id="8", server_id="server2") - result2, _ = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context2 - ) - - assert result2.continue_processing - assert result2.modified_payload is None # Plugin skipped - - # Test with tool name mismatch - tool_payload2 = ToolPreInvokePayload(name="other_tool", args={}) - global_context3 = GlobalContext(request_id="9", server_id="server1") - result3, _ = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload2, global_context=global_context3 - ) - - assert result3.continue_processing - assert result3.modified_payload is None # Plugin skipped - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_metadata_aggregation(): - """Test metadata aggregation from multiple plugins.""" - - class MetadataPlugin1(Plugin): - async def prompt_pre_fetch(self, payload, context): - return PluginResult(continue_processing=True, metadata={"plugin1": "data1", "shared": "value1"}) - - class MetadataPlugin2(Plugin): - async def prompt_pre_fetch(self, payload, context): - return PluginResult( - continue_processing=True, - metadata={"plugin2": "data2", "shared": "value2"}, # Overwrites shared - ) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - config1 = PluginConfig( - name="Plugin1", - description="Metadata plugin 1", - author="Test", - version="1.0", - tags=["test"], - kind="Plugin1", - hooks=["prompt_pre_fetch"], - config={}, - ) - config2 = PluginConfig( - name="Plugin2", - description="Metadata plugin 2", - author="Test", - version="1.0", - tags=["test"], - kind="Plugin2", - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin1 = MetadataPlugin1(config1) - plugin2 = MetadataPlugin2(config2) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - refs = [ - HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)), - HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)), - ] - mock_get.return_value = refs - - prompt = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="1") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Should aggregate metadata - assert result.continue_processing - assert result.metadata["plugin1"] == "data1" - assert result.metadata["plugin2"] == "data2" - assert result.metadata["shared"] == "value2" # Last one wins - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_local_context_persistence(): - """Test that local contexts persist across hook calls.""" - - class StatefulPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context: PluginContext): - context.state["counter"] = context.state.get("counter", 0) + 1 - return PluginResult(continue_processing=True) - - async def prompt_post_fetch(self, payload, context: PluginContext): - # Should see the state from pre_fetch - counter = context.state.get("counter", 0) - payload.result.messages[0].content.text = f"Counter: {counter}" - return PluginResult(continue_processing=True, modified_payload=payload) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - config = PluginConfig( - name="StatefulPlugin", - description="Test stateful plugin", - author="Test", - version="1.0", - tags=["test"], - kind="StatefulPlugin", - hooks=["prompt_pre_fetch", "prompt_post_fetch"], - config={}, - ) - plugin = StatefulPlugin(config) - - # Create a single PluginRef to ensure the same UUID is used for both hooks - plugin_ref = PluginRef(plugin) - hook_ref_pre = HookRef(PromptHookType.PROMPT_PRE_FETCH, plugin_ref) - hook_ref_post = HookRef(PromptHookType.PROMPT_POST_FETCH, plugin_ref) - - def get_hook_refs_side_effect(hook_type): - if hook_type == PromptHookType.PROMPT_PRE_FETCH: - return [hook_ref_pre] - elif hook_type == PromptHookType.PROMPT_POST_FETCH: - return [hook_ref_post] - return [] - - with patch.object(manager._registry, "get_hook_refs_for_hook", side_effect=get_hook_refs_side_effect): - # First call to pre_fetch - prompt = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="1") - - result_pre, contexts = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context - ) - assert result_pre.continue_processing - - # Call to post_fetch with same contexts - message = Message(content=TextContent(type="text", text="Original"), role=Role.USER) - prompt_result = PromptResult(messages=[message]) - post_payload = PromptPosthookPayload(prompt_id="test", result=prompt_result) - - result_post, _ = await manager.invoke_hook( - PromptHookType.PROMPT_POST_FETCH, post_payload, global_context=global_context, local_contexts=contexts - ) - - # Should have modified with persisted state - assert result_post.continue_processing - assert result_post.modified_payload is not None - assert "Counter: 1" in result_post.modified_payload.result.messages[0].content.text - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_plugin_blocking(): - """Test plugin blocking behavior in concurrent mode.""" - - class BlockingPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - violation = PluginViolation( - reason="Content violation", - description="Blocked content detected", - code="CONTENT_BLOCKED", - details={"content": payload.args}, - ) - return PluginResult(continue_processing=False, violation=violation) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - config = PluginConfig( - name="BlockingPlugin", - description="Test blocking plugin", - author="Test", - version="1.0", - tags=["test"], - kind="BlockingPlugin", - mode=PluginMode.CONCURRENT, - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin = BlockingPlugin(config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - prompt = PromptPrehookPayload(prompt_id="test", args={"text": "bad content"}) - global_context = GlobalContext(request_id="1") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Should block the request - assert not result.continue_processing - assert result.violation is not None - assert result.violation.code == "CONTENT_BLOCKED" - assert result.violation.plugin_name == "BlockingPlugin" - - with pytest.raises(PluginViolationError) as pve: - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context, violations_as_exceptions=True - ) - assert pve.value.violation - assert pve.value.message - assert pve.value.violation.code == "CONTENT_BLOCKED" - assert pve.value.violation.plugin_name == "BlockingPlugin" - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_plugin_audit_blocking(): - """Test plugin behavior when blocking in audit mode.""" - - class BlockingPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - violation = PluginViolation( - reason="Would block", description="Content would be blocked", code="WOULD_BLOCK" - ) - return PluginResult(continue_processing=False, violation=violation) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - config = PluginConfig( - name="BlockingPlugin", - description="Test audit blocking plugin", - author="Test", - version="1.0", - tags=["test"], - kind="BlockingPlugin", - mode=PluginMode.AUDIT, # Audit mode - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin = BlockingPlugin(config) - - # Test audit mode blocking - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - prompt = PromptPrehookPayload(prompt_id="test", args={"text": "content"}) - global_context = GlobalContext(request_id="1") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Should continue - assert result.continue_processing - # No violation returned - assert not result.violation - - await manager.shutdown() - - -# Test removed - file path handling is too complex for this test context - - -# Test removed - property mocking too complex for this test context - - -@pytest.mark.asyncio -async def test_manager_shutdown_behavior(): - """Test manager shutdown behavior.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - await manager.initialize() - assert manager.initialized - - # First shutdown - await manager.shutdown() - assert not manager.initialized - - # Second shutdown should be idempotent - await manager.shutdown() - assert not manager.initialized - - -# Test removed - testing internal executor implementation details is too complex - - -@pytest.mark.asyncio -async def test_manager_payload_size_validation(): - """Test payload size validation functionality.""" - # First-Party - from cpex.framework import PromptPosthookPayload, PromptPrehookPayload - from cpex.framework.manager import MAX_PAYLOAD_SIZE, PayloadSizeError, PluginExecutor - - # Test payload size validation directly on executor (covers lines 252, 258) - executor = PluginExecutor() - - # Test large args payload (covers line 252) - large_data = "x" * (MAX_PAYLOAD_SIZE + 1) - large_prompt = PromptPrehookPayload(prompt_id="test", args={"large": large_data}) - - # Should raise PayloadSizeError for large args - with pytest.raises(PayloadSizeError, match="Payload size .* exceeds limit"): - executor._validate_payload_size(large_prompt) - - # Test large result payload (covers line 258) - large_text = "y" * (MAX_PAYLOAD_SIZE + 1) - message = Message(role="user", content=TextContent(type="text", text=large_text)) - large_result = PromptResult(messages=[message]) - large_post_payload = PromptPosthookPayload(prompt_id="test", result=large_result) - - # Should raise PayloadSizeError for large result - executor2 = PluginExecutor() - with pytest.raises(PayloadSizeError, match="Result size .* exceeds limit"): - executor2._validate_payload_size(large_post_payload) - - -@pytest.mark.asyncio -async def test_manager_initialization_edge_cases(): - """Test manager initialization edge cases.""" - - # Test manager already initialized (covers lines 481-482) - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - with patch("cpex.framework.manager.logger") as mock_logger: - # Initialize again - should skip - await manager.initialize() - mock_logger.debug.assert_called_with("Plugin manager already initialized") - - await manager.shutdown() - - # Test plugin instantiation failure (covers lines 495-501) - # First-Party - from cpex.framework.models import PluginConfig, PluginMode - - manager2 = PluginManager() - manager2._config = Config( - plugins=[ - PluginConfig( - name="FailingPlugin", - description="Plugin that fails to instantiate", - author="Test", - version="1.0", - tags=["test"], - kind="nonexistent.Plugin", - mode=PluginMode.CONCURRENT, - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - ], - ) - - # Mock the loader to return None (covers lines 495-496) - # Explicitly enable fail_on_plugin_error so the RuntimeError is raised - with ( - patch.object(manager2._loader, "load_and_instantiate_plugin", return_value=None), - patch("cpex.framework.manager.settings") as mock_settings, - ): - mock_settings.fail_on_plugin_error = True - with pytest.raises(RuntimeError, match="Plugin initialization failed: FailingPlugin"): - await manager2.initialize() - - # Test disabled plugin (covers line 501) - manager3 = PluginManager() - manager3._config = Config( - plugins=[ - PluginConfig( - name="DisabledPlugin", - description="Disabled plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - mode=PluginMode.DISABLED, # Disabled mode - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - ], - ) - - await manager3.shutdown() - await manager2.shutdown() - - -@pytest.mark.asyncio -async def test_base_plugin_coverage(): - """Test base plugin functionality for complete coverage.""" - # First-Party - from cpex.framework import ( - GlobalContext, - PluginConfig, - PluginContext, - PluginMode, - PromptHookType, - PromptPosthookPayload, - PromptPrehookPayload, - ToolPostInvokePayload, - ToolPreInvokePayload, - ) - from cpex.framework.base import PluginRef - - # Test plugin with tags property (covers line 130) - config = PluginConfig( - name="TestPlugin", - description="Test plugin for coverage", - author="Test", - version="1.0", - tags=["test", "coverage"], # Tags to be accessed - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - - plugin = Plugin(config) - - # Test tags property - assert plugin.tags == ["test", "coverage"] - - # Test PluginRef tags property (covers line 326) - plugin_ref = PluginRef(plugin) - assert plugin_ref.tags == ["test", "coverage"] - - # Test PluginRef mode property (covers line 344) - assert plugin_ref.mode == PluginMode.SEQUENTIAL # Default mode - - # Test NotImplementedError for prompt_pre_fetch (covers lines 151-155) - context = PluginContext(global_context=GlobalContext(request_id="test")) - payload = PromptPrehookPayload(prompt_id="test", args={}) - - with pytest.raises(AttributeError, match="'Plugin' object has no attribute 'prompt_pre_fetch'"): - await plugin.prompt_pre_fetch(payload, context) - - # Test NotImplementedError for prompt_post_fetch (covers lines 167-171) - message = Message(role="user", content=TextContent(type="text", text="test")) - result = PromptResult(messages=[message]) - post_payload = PromptPosthookPayload(prompt_id="test", result=result) - - with pytest.raises(AttributeError, match="'Plugin' object has no attribute 'prompt_post_fetch'"): - await plugin.prompt_post_fetch(post_payload, context) - - # Test default tool_pre_invoke implementation (covers line 191) - tool_payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - with pytest.raises(AttributeError, match="'Plugin' object has no attribute 'tool_pre_invoke'"): - await plugin.tool_pre_invoke(tool_payload, context) - - # Test default tool_post_invoke implementation (covers line 211) - tool_post_payload = ToolPostInvokePayload(name="test_tool", result={"result": "success"}) - with pytest.raises(AttributeError, match="'Plugin' object has no attribute 'tool_post_invoke'"): - await plugin.tool_post_invoke(tool_post_payload, context) - - -@pytest.mark.asyncio -async def test_plugin_types_coverage(): - """Test plugin types functionality for complete coverage.""" - # First-Party - from cpex.framework.errors import PluginViolationError - from cpex.framework.models import PluginContext, PluginViolation - - # Test PluginContext state methods (covers lines 266, 275) - plugin_ctx = PluginContext(global_context=GlobalContext(request_id="test", user="testuser")) - - # Test get_state with default - assert plugin_ctx.get_state("nonexistent", "default_value") == "default_value" - - # Test set_state - plugin_ctx.set_state("test_key", "test_value") - assert plugin_ctx.get_state("test_key") == "test_value" - - # Test cleanup method (covers lines 279-281) - plugin_ctx.state["keep_me"] = "data" - plugin_ctx.metadata["meta"] = "info" - - await plugin_ctx.cleanup() - - assert len(plugin_ctx.state) == 0 - assert len(plugin_ctx.metadata) == 0 - - # Test PluginViolationError (covers lines 301-303) - violation = PluginViolation( - reason="Test violation", description="Test description", code="TEST_CODE", details={"key": "value"} - ) - - error = PluginViolationError("Test message", violation) - - assert error.message == "Test message" - assert error.violation is violation - assert str(error) == "Test message" - - -@pytest.mark.asyncio -async def test_plugin_loader_return_none(): - """Test plugin loader return None case.""" - # First-Party - from cpex.framework import PluginConfig - from cpex.framework.loader.plugin import PluginLoader - - loader = PluginLoader() - - # Test return None when plugin_type is None (covers line 90) - config = PluginConfig( - name="TestPlugin", - description="Test", - author="Test", - version="1.0", - tags=["test"], - kind="test.plugin.TestPlugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - - # Mock the plugin_types dict to contain None for this kind - loader._plugin_types[config.kind] = None - - result = await loader.load_and_instantiate_plugin(config) - assert result is None - - -def test_plugin_violation_setter_validation(): - """Test PluginViolation plugin_name setter validation.""" - # First-Party - from cpex.framework.models import PluginViolation - - violation = PluginViolation( - reason="Test", description="Test description", code="TEST_CODE", details={"key": "value"} - ) - - # Test valid plugin name setting - violation.plugin_name = "valid_plugin_name" - assert violation.plugin_name == "valid_plugin_name" - - # Test empty string raises ValueError (covers line 269) - with pytest.raises(ValueError, match="Name must be a non-empty string"): - violation.plugin_name = "" - - # Test whitespace-only string raises ValueError - with pytest.raises(ValueError, match="Name must be a non-empty string"): - violation.plugin_name = " " - - # Test non-string raises ValueError - with pytest.raises(ValueError, match="Name must be a non-empty string"): - violation.plugin_name = 123 - - -@pytest.mark.asyncio -async def test_manager_compare_function_wrapper(): - """Test the compare function wrapper in _run_plugins.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - # The compare function is used internally in _run_plugins - # Test by using plugins with conditions - class TestPlugin(Plugin): - async def tool_pre_invoke(self, payload, context): - return PluginResult(continue_processing=True) - - config = PluginConfig( - name="TestPlugin", - description="Test plugin for conditions", - author="Test", - version="1.0", - tags=["test"], - kind="TestPlugin", - hooks=["tool_pre_invoke"], - config={}, - conditions=[PluginCondition(tools={"calculator"})], - ) - plugin = TestPlugin(config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(ToolHookType.TOOL_PRE_INVOKE, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - # Test with matching tool - tool_payload = ToolPreInvokePayload(name="calculator", args={}) - global_context = GlobalContext(request_id="1") - - result, _ = await manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, tool_payload, global_context=global_context) - assert result.continue_processing - - # Test with non-matching tool - tool_payload2 = ToolPreInvokePayload(name="other_tool", args={}) - result2, _ = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, tool_payload2, global_context=global_context - ) - assert result2.continue_processing - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_tool_post_invoke_coverage(): - """Test tool_post_invoke with various scenarios.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - class ModifyingPlugin(Plugin): - async def tool_post_invoke(self, payload, context): - payload.result["modified"] = True - return PluginResult(continue_processing=True, modified_payload=payload) - - config = PluginConfig( - name="ModifyingPlugin", - description="Test modifying plugin", - author="Test", - version="1.0", - tags=["test"], - kind="ModifyingPlugin", - hooks=["tool_post_invoke"], - config={}, - ) - plugin = ModifyingPlugin(config) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(ToolHookType.TOOL_POST_INVOKE, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - tool_payload = ToolPostInvokePayload(name="test_tool", result={"original": "data"}) - global_context = GlobalContext(request_id="1") - - result, _ = await manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, tool_payload, global_context=global_context - ) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.result["modified"] is True - assert result.modified_payload.result["original"] == "data" - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_initialize_skips_plugin_load_errors_when_configured(): - """Startup should continue when plugin init fails and fail_on_plugin_error is false.""" - PluginManager.reset() - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - - with ( - patch.object(manager._loader, "load_and_instantiate_plugin", side_effect=RuntimeError("plugin offline")), - patch("cpex.framework.manager.logger") as mock_logger, - ): - await manager.initialize() - - mock_logger.warning.assert_called_with( - "Skipping plugin %s because fail_on_plugin_error is disabled", "ReplaceBadWordsPlugin" - ) - - assert manager.initialized is True - assert manager.plugin_count == 0 - - await manager.shutdown() - PluginManager.reset() - - -@pytest.mark.asyncio -async def test_plugin_result_retry_delay_ms(): - """PluginResult should accept and default retry_delay_ms.""" - result = PluginResult() - assert result.retry_delay_ms == 0 - - result2 = PluginResult(retry_delay_ms=500) - assert result2.retry_delay_ms == 500 - - # Serialization roundtrip - data = result2.model_dump() - assert data["retry_delay_ms"] == 500 - - -@pytest.mark.asyncio -async def test_executor_propagates_retry_delay_ms(): - """Executor should propagate the maximum retry_delay_ms across plugins.""" - - class RetryPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - return PluginResult(retry_delay_ms=200) - - class RetryPlugin2(Plugin): - async def prompt_pre_fetch(self, payload, context): - return PluginResult(retry_delay_ms=500) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - config1 = PluginConfig( - name="RetryPlugin1", - description="Test", - author="Test", - version="1.0", - tags=["test"], - kind="RetryPlugin", - mode=PluginMode.SEQUENTIAL, - hooks=["prompt_pre_fetch"], - config={}, - ) - config2 = PluginConfig( - name="RetryPlugin2", - description="Test", - author="Test", - version="1.0", - tags=["test"], - kind="RetryPlugin2", - mode=PluginMode.SEQUENTIAL, - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin1 = RetryPlugin(config1) - plugin2 = RetryPlugin2(config2) - - prompt = PromptPrehookPayload(prompt_id="123", name="test", args={}) - global_context = GlobalContext(request_id="1") - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - hook_ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - mock_get.return_value = [hook_ref1, hook_ref2] - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, prompt, global_context=global_context) - - # Should propagate the maximum retry delay (500) - assert result.retry_delay_ms == 500 - - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_manager_extensions.py b/tests/unit/cpex/framework/test_manager_extensions.py deleted file mode 100644 index a8d6ad31..00000000 --- a/tests/unit/cpex/framework/test_manager_extensions.py +++ /dev/null @@ -1,201 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for PluginManager with extensions-aware plugins. - -Covers: -- _execute_with_timeout with accepts_extensions=True -- Plugin receives capability-filtered extensions -- Extensions passed through invoke_hook reach the plugin -""" - -import pytest - -from cpex.framework import ( - GlobalContext, - PluginManager, - ToolHookType, - ToolPreInvokePayload, -) -from cpex.framework.extensions.extensions import Extensions -from cpex.framework.extensions.security import ( - SecurityExtension, - SubjectExtension, - SubjectType, -) - - -@pytest.mark.asyncio -async def test_manager_extensions_aware_plugin_receives_extensions(): - """An extensions-aware plugin (3-param hook) receives filtered extensions.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml") - await manager.initialize() - - extensions = Extensions( - security=SecurityExtension( - labels=frozenset({"internal"}), - subject=SubjectExtension( - id="alice@corp.com", - type=SubjectType.USER, - roles=frozenset({"required_role", "engineer"}), - permissions=frozenset({"tool_execute"}), - ), - ), - ) - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - context = GlobalContext(request_id="req-1") - - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, - payload, - global_context=context, - extensions=extensions, - ) - - assert result.continue_processing is True - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_extensions_aware_plugin_without_extensions(): - """An extensions-aware plugin works when no extensions are passed.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml") - await manager.initialize() - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - context = GlobalContext(request_id="req-1") - - # No extensions passed — plugin should still work (backward compat) - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, - payload, - global_context=context, - ) - - assert result.continue_processing is True - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_extensions_capability_filtering(): - """Extensions are filtered by the plugin's declared capabilities.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/extensions_aware_plugin.yaml") - await manager.initialize() - - # Create extensions with HTTP headers — plugin doesn't have read_headers capability - from cpex.framework.extensions.http import HttpExtension - - extensions = Extensions( - security=SecurityExtension( - labels=frozenset({"PII"}), - subject=SubjectExtension( - id="alice@corp.com", - type=SubjectType.USER, - roles=frozenset({"engineer"}), - ), - ), - http=HttpExtension(headers={"authorization": "Bearer secret"}), - ) - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - context = GlobalContext(request_id="req-1") - - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, - payload, - global_context=context, - extensions=extensions, - ) - - # Plugin should execute successfully — it has read_subject and read_labels - # but NOT read_headers, so HTTP headers should be filtered out by the framework - assert result.continue_processing is True - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_plugin_adds_label_and_manager_merges(): - """Plugin adds a label to extensions; manager merges it back in the result.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/extensions_label_plugin.yaml") - await manager.initialize() - - extensions = Extensions( - security=SecurityExtension( - labels=frozenset({"internal"}), - ), - ) - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - context = GlobalContext(request_id="req-1") - - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, - payload, - global_context=context, - extensions=extensions, - ) - - assert result.continue_processing is True - - # The plugin should have added 'PLUGIN_TOUCHED' label via modified_extensions - assert result.modified_extensions is not None - assert "PLUGIN_TOUCHED" in result.modified_extensions.security.labels - # Original label should still be present (monotonic — only growth) - assert "internal" in result.modified_extensions.security.labels - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_plugin_writes_custom_extensions(): - """Plugin reads labels and writes observation to custom extensions.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/extensions_custom_plugin.yaml") - await manager.initialize() - - extensions = Extensions( - security=SecurityExtension( - labels=frozenset({"PII", "financial"}), - ), - ) - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - context = GlobalContext(request_id="req-1") - - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, - payload, - global_context=context, - extensions=extensions, - ) - - assert result.continue_processing is True - # Plugin should have written pii_detected=True to custom extensions - assert result.modified_extensions is not None - assert result.modified_extensions.custom["pii_detected"] is True - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_manager_plugin_custom_no_pii(): - """Plugin reads labels — no PII label means pii_detected=False.""" - manager = PluginManager("./tests/unit/cpex/fixtures/configs/extensions_custom_plugin.yaml") - await manager.initialize() - - extensions = Extensions( - security=SecurityExtension( - labels=frozenset({"internal"}), - ), - ) - - payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) - context = GlobalContext(request_id="req-1") - - result, contexts = await manager.invoke_hook( - ToolHookType.TOOL_PRE_INVOKE, - payload, - global_context=context, - extensions=extensions, - ) - - assert result.continue_processing is True - assert result.modified_extensions is not None - assert result.modified_extensions.custom["pii_detected"] is False - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_manager_runtime_disabled.py b/tests/unit/cpex/framework/test_manager_runtime_disabled.py deleted file mode 100644 index 012b3d09..00000000 --- a/tests/unit/cpex/framework/test_manager_runtime_disabled.py +++ /dev/null @@ -1,156 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_manager_runtime_disabled.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Tests for PluginExecutor._runtime_disabled: - - persistent-per-executor lifetime across multiple execute() calls - - asyncio.Lock guards mutations under concurrent failures - - reset_runtime_disabled() clears the set under the same lock -""" - -# Standard -import asyncio -from unittest.mock import patch - -# Third-Party -import pytest - -from cpex.framework import ( - GlobalContext, - OnError, - Plugin, - PluginConfig, - PluginManager, - PluginMode, - PluginResult, - PromptHookType, - PromptPrehookPayload, -) - -# First-Party -from cpex.framework.base import HookRef -from cpex.framework.registry import PluginRef - - -def _cfg(name: str, on_error: OnError = OnError.DISABLE) -> PluginConfig: - return PluginConfig( - name=name, - description="test", - author="test", - version="1.0", - kind="test.Plugin", - mode=PluginMode.CONCURRENT, - on_error=on_error, - hooks=["prompt_pre_fetch"], - tags=[], - priority=100, - ) - - -async def _make_manager() -> PluginManager: - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - return manager - - -@pytest.mark.asyncio -async def test_runtime_disabled_persists_across_execute_calls(): - """OnError.DISABLE is a persistent decision: a disabled plugin stays disabled - across subsequent execute() calls on the same executor.""" - - call_count = 0 - - class FailingPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - nonlocal call_count - call_count += 1 - raise RuntimeError("boom") - - manager = await _make_manager() - plugin = FailingPlugin(_cfg("Persistent")) - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [hook_ref] - payload = PromptPrehookPayload(prompt_id="t", args={}) - ctx = GlobalContext(request_id="1") - - for _ in range(5): - await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, ctx) - - assert call_count == 1 - assert "Persistent" in manager._executor._runtime_disabled - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_runtime_disabled_concurrent_failures_dont_corrupt_set(): - """Concurrent OnError.DISABLE failures across multiple plugins all land in the - set. With the asyncio.Lock guarding mutations, no plugin name is dropped.""" - - plugin_names = [f"P{i}" for i in range(20)] - - class FailingPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - raise RuntimeError("boom") - - manager = await _make_manager() - plugins = [FailingPlugin(_cfg(n)) for n in plugin_names] - hook_refs = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(p)) for p in plugins] - - async def trip(hook_ref: HookRef) -> None: - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [hook_ref] - await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - PromptPrehookPayload(prompt_id="t", args={}), - GlobalContext(request_id="x"), - ) - - await asyncio.gather(*(trip(hr) for hr in hook_refs)) - - assert manager._executor._runtime_disabled == set(plugin_names) - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_reset_runtime_disabled_clears_and_reenables(): - """reset_runtime_disabled() empties the set; subsequent invocations re-run the plugin.""" - - call_count = 0 - - class FlakyPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise RuntimeError("first call fails") - return PluginResult(continue_processing=True) - - manager = await _make_manager() - plugin = FlakyPlugin(_cfg("Flaky")) - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [hook_ref] - payload = PromptPrehookPayload(prompt_id="t", args={}) - ctx = GlobalContext(request_id="r") - - await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, ctx) - assert "Flaky" in manager._executor._runtime_disabled - # Skipped while disabled - await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, ctx) - assert call_count == 1 - - await manager._executor.reset_runtime_disabled() - assert manager._executor._runtime_disabled == set() - - # Now runs again, succeeds this time - await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, ctx) - assert call_count == 2 - assert "Flaky" not in manager._executor._runtime_disabled - - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_memory.py b/tests/unit/cpex/framework/test_memory.py deleted file mode 100644 index 30ff6221..00000000 --- a/tests/unit/cpex/framework/test_memory.py +++ /dev/null @@ -1,1592 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_memory.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests for memory module. -""" - -# Standard -import weakref - -# Third-Party -import pytest -from pydantic import BaseModel, ConfigDict, Field, RootModel - -# First-Party -from cpex.framework.memory import ( - CopyOnWriteDict, - CopyOnWriteList, - _safe_deepcopy, - _wrap_value, - copyonwrite, - wrap_payload_for_isolation, -) - - -class TestCopyOnWriteDict: - """Test suite for CopyOnWriteDict class.""" - - def test_is_dict_subclass(self): - """Test that CopyOnWriteDict is a subclass of dict.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - assert isinstance(cow, dict) - assert issubclass(CopyOnWriteDict, dict) - - def test_initialization(self): - """Test that CopyOnWriteDict initializes correctly.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - # Verify all original keys are accessible - assert cow["a"] == 1 - assert cow["b"] == 2 - assert cow["c"] == 3 - - # Verify original is unchanged - assert original == {"a": 1, "b": 2, "c": 3} - - def test_initialization_empty_dict(self): - """Test initialization with an empty dictionary.""" - original = {} - cow = CopyOnWriteDict(original) - - assert len(cow) == 0 - assert list(cow.keys()) == [] - - def test_getitem_existing_key(self): - """Test getting an existing key from the original dict.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - assert cow["a"] == 1 - assert cow["b"] == 2 - - def test_getitem_nonexistent_key(self): - """Test that getting a non-existent key raises KeyError.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - with pytest.raises(KeyError): - _ = cow["nonexistent"] - - def test_getitem_deleted_key(self): - """Test that getting a deleted key raises KeyError.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - - with pytest.raises(KeyError): - _ = cow["a"] - - def test_setitem_new_key(self): - """Test setting a new key that doesn't exist in original.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - - assert cow["b"] == 2 - assert "b" not in original # Original unchanged - assert original == {"a": 1} - - def test_setitem_override_existing_key(self): - """Test overriding an existing key from the original dict.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - - assert cow["a"] == 10 - assert original["a"] == 1 # Original unchanged - - def test_setitem_after_delete(self): - """Test setting a key after it was deleted.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - cow["a"] = 10 - - assert cow["a"] == 10 - assert "a" not in cow.get_deleted() - - def test_delitem_existing_key(self): - """Test deleting an existing key from the original dict.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - - assert "a" not in cow - assert "a" in original # Original unchanged - assert original == {"a": 1, "b": 2} - - def test_delitem_modified_key(self): - """Test deleting a key that was modified.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - del cow["a"] - - assert "a" not in cow - assert original["a"] == 1 # Original unchanged - - def test_delitem_new_key(self): - """Test deleting a key that was added to modifications.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - del cow["b"] - - assert "b" not in cow - - def test_delitem_nonexistent_key(self): - """Test that deleting a non-existent key raises KeyError.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - with pytest.raises(KeyError): - del cow["nonexistent"] - - def test_delitem_already_deleted(self): - """Test that deleting an already deleted key raises KeyError.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - del cow["a"] - - with pytest.raises(KeyError): - del cow["a"] - - def test_contains_existing_key(self): - """Test __contains__ for an existing key.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - assert "a" in cow - assert "b" in cow - - def test_contains_new_key(self): - """Test __contains__ for a newly added key.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - - assert "b" in cow - - def test_contains_nonexistent_key(self): - """Test __contains__ for a non-existent key.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - assert "nonexistent" not in cow - - def test_contains_deleted_key(self): - """Test __contains__ for a deleted key.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - - assert "a" not in cow - assert "b" in cow - - def test_len_original_only(self): - """Test __len__ with only original keys.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - assert len(cow) == 3 - - def test_len_with_additions(self): - """Test __len__ with added keys.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - cow["c"] = 3 - - assert len(cow) == 3 - - def test_len_with_deletions(self): - """Test __len__ with deleted keys.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - del cow["a"] - del cow["b"] - - assert len(cow) == 1 - - def test_len_with_modifications(self): - """Test __len__ with modifications (should not change length).""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - - assert len(cow) == 2 - - def test_len_empty(self): - """Test __len__ when all keys are deleted.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - del cow["a"] - - assert len(cow) == 0 - - def test_iter_original_only(self): - """Test __iter__ with only original keys.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - keys = list(cow) - assert set(keys) == {"a", "b", "c"} - - def test_iter_with_additions(self): - """Test __iter__ with added keys.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - cow["c"] = 3 - - keys = list(cow) - assert set(keys) == {"a", "b", "c"} - - def test_iter_with_deletions(self): - """Test __iter__ with deleted keys.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - del cow["b"] - - keys = list(cow) - assert set(keys) == {"a", "c"} - - def test_get_existing_key(self): - """Test get() method for an existing key.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - assert cow.get("a") == 1 - assert cow.get("b") == 2 - - def test_get_nonexistent_key_default_none(self): - """Test get() method for non-existent key with default None.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - assert cow.get("nonexistent") is None - - def test_get_nonexistent_key_custom_default(self): - """Test get() method for non-existent key with custom default.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - assert cow.get("nonexistent", "default") == "default" - - def test_get_deleted_key(self): - """Test get() method for a deleted key returns default.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - - assert cow.get("a") is None - assert cow.get("a", "default") == "default" - - def test_keys_original_only(self): - """Test keys() method with only original keys.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - keys = list(cow.keys()) - assert set(keys) == {"a", "b", "c"} - - def test_keys_with_modifications(self): - """Test keys() method with modifications.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["c"] = 3 - del cow["a"] - - keys = list(cow.keys()) - assert set(keys) == {"b", "c"} - - def test_values_original_only(self): - """Test values() method with only original values.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - values = list(cow.values()) - assert set(values) == {1, 2, 3} - - def test_values_with_modifications(self): - """Test values() method with modifications.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - cow["c"] = 3 - del cow["b"] - - values = list(cow.values()) - assert set(values) == {10, 3} - - def test_items_original_only(self): - """Test items() method with only original items.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - items = list(cow.items()) - assert set(items) == {("a", 1), ("b", 2), ("c", 3)} - - def test_items_with_modifications(self): - """Test items() method with modifications.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - cow["c"] = 3 - del cow["b"] - - items = list(cow.items()) - assert set(items) == {("a", 10), ("c", 3)} - - def test_copy_original_only(self): - """Test copy() method with only original data.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - copied = cow.copy() - - assert copied == {"a": 1, "b": 2, "c": 3} - assert isinstance(copied, dict) - assert copied is not original - - def test_copy_with_modifications(self): - """Test copy() method with modifications.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - cow["c"] = 3 - del cow["b"] - - copied = cow.copy() - - assert copied == {"a": 10, "c": 3} - assert isinstance(copied, dict) - - def test_get_modifications_no_changes(self): - """Test get_modifications() with no changes.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - mods = cow.get_modifications() - - assert mods == {} - - def test_get_modifications_with_additions(self): - """Test get_modifications() with added keys.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - cow["c"] = 3 - - mods = cow.get_modifications() - - assert mods == {"b": 2, "c": 3} - - def test_get_modifications_with_overrides(self): - """Test get_modifications() with overridden keys.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - cow["c"] = 3 - - mods = cow.get_modifications() - - assert mods == {"a": 10, "c": 3} - - def test_get_modifications_with_deletions(self): - """Test get_modifications() after deletions.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow["c"] = 3 - del cow["b"] - - mods = cow.get_modifications() - - # Deletions are not in modifications, only in deleted set - assert mods == {"c": 3} - - def test_get_modifications_returns_copy(self): - """Test that get_modifications() returns a copy, not the original.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - - mods1 = cow.get_modifications() - mods2 = cow.get_modifications() - - assert mods1 == mods2 - assert mods1 is not mods2 - - def test_get_deleted_no_deletions(self): - """Test get_deleted() with no deletions.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - deleted = cow.get_deleted() - - assert deleted == set() - - def test_get_deleted_with_deletions(self): - """Test get_deleted() with deleted keys.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - del cow["a"] - del cow["c"] - - deleted = cow.get_deleted() - - assert deleted == {"a", "c"} - - def test_get_deleted_returns_copy(self): - """Test that get_deleted() returns a copy, not the original set.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - - deleted1 = cow.get_deleted() - deleted2 = cow.get_deleted() - - assert deleted1 == deleted2 - assert deleted1 is not deleted2 - - def test_has_modifications_false(self): - """Test has_modifications() returns False with no changes.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - assert not cow.has_modifications() - - def test_has_modifications_true_with_additions(self): - """Test has_modifications() returns True with additions.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - - assert cow.has_modifications() - - def test_has_modifications_true_with_overrides(self): - """Test has_modifications() returns True with overrides.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - - assert cow.has_modifications() - - def test_has_modifications_true_with_deletions(self): - """Test has_modifications() returns True with deletions.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - del cow["a"] - - assert cow.has_modifications() - - def test_repr(self): - """Test __repr__ method.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - repr_str = repr(cow) - - assert "CopyOnWriteDict" in repr_str - assert "a" in repr_str or "1" in repr_str - - def test_repr_with_modifications(self): - """Test __repr__ with modifications.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["b"] = 2 - del cow["a"] - - repr_str = repr(cow) - - assert "CopyOnWriteDict" in repr_str - assert "b" in repr_str or "2" in repr_str - - def test_complex_workflow(self): - """Test a complex workflow with multiple operations.""" - original = {"a": 1, "b": 2, "c": 3, "d": 4} - cow = CopyOnWriteDict(original) - - # Perform various operations - cow["a"] = 10 # Override - cow["e"] = 5 # Add new - del cow["b"] # Delete - cow["c"] = 30 # Override - - # Verify state - assert cow["a"] == 10 - assert "b" not in cow - assert cow["c"] == 30 - assert cow["d"] == 4 - assert cow["e"] == 5 - - # Verify original unchanged - assert original == {"a": 1, "b": 2, "c": 3, "d": 4} - - # Verify modifications - assert cow.get_modifications() == {"a": 10, "c": 30, "e": 5} - assert cow.get_deleted() == {"b"} - assert cow.has_modifications() - - # Verify copy - assert cow.copy() == {"a": 10, "c": 30, "d": 4, "e": 5} - - def test_original_dict_mutations_not_reflected(self): - """Test that mutations to the original dict after COW creation are visible.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - # Mutate original - this WILL be visible in COW since ChainMap references the original - original["c"] = 3 - - # ChainMap references the original, so this change is visible - assert cow["c"] == 3 - - def test_nested_values(self): - """Test that nested values work correctly.""" - original = {"a": {"nested": 1}, "b": [1, 2, 3]} - cow = CopyOnWriteDict(original) - - # Read nested values - assert cow["a"] == {"nested": 1} - assert cow["b"] == [1, 2, 3] - - # Modify nested value - cow["a"] = {"nested": 10} - - assert cow["a"] == {"nested": 10} - assert original["a"] == {"nested": 1} - - def test_duplicate_operations(self): - """Test duplicate operations on the same key.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - # Multiple modifications to same key - cow["a"] = 10 - cow["a"] = 20 - cow["a"] = 30 - - assert cow["a"] == 30 - assert cow.get_modifications() == {"a": 30} - - def test_delete_and_recreate(self): - """Test deleting a key and then recreating it.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - del cow["a"] - assert "a" not in cow - - cow["a"] = 10 - assert cow["a"] == 10 - assert "a" not in cow.get_deleted() - - def test_update_with_dict(self): - """Test update() method with a dictionary.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow.update({"b": 2, "c": 3}) - - assert cow["a"] == 1 - assert cow["b"] == 2 - assert cow["c"] == 3 - assert original == {"a": 1} - - def test_update_with_kwargs(self): - """Test update() method with keyword arguments.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow.update(b=2, c=3) - - assert cow["b"] == 2 - assert cow["c"] == 3 - - def test_update_with_both(self): - """Test update() method with both dict and kwargs.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow.update({"b": 2}, c=3, d=4) - - assert cow["b"] == 2 - assert cow["c"] == 3 - assert cow["d"] == 4 - - def test_update_with_iterable(self): - """Test update() method with an iterable of pairs.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow.update([("b", 2), ("c", 3)]) - - assert cow["b"] == 2 - assert cow["c"] == 3 - - def test_update_overwrites_existing(self): - """Test that update() overwrites existing keys.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow.update({"a": 10, "c": 3}) - - assert cow["a"] == 10 - assert cow["b"] == 2 - assert cow["c"] == 3 - assert original["a"] == 1 - - def test_pop_existing_key(self): - """Test pop() method with existing key.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - value = cow.pop("a") - - assert value == 1 - assert "a" not in cow - assert original == {"a": 1, "b": 2} - - def test_pop_nonexistent_key_with_default(self): - """Test pop() method with non-existent key and default.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - value = cow.pop("nonexistent", "default") - - assert value == "default" - - def test_pop_nonexistent_key_no_default(self): - """Test pop() method with non-existent key and no default raises KeyError.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - with pytest.raises(KeyError): - cow.pop("nonexistent") - - def test_pop_too_many_args(self): - """Test pop() with too many arguments raises TypeError.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - with pytest.raises(TypeError, match="pop\\(\\) accepts 1 or 2 arguments"): - cow.pop("a", "default1", "default2") - - def test_pop_modified_key(self): - """Test pop() on a key that was modified.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - cow["a"] = 10 - value = cow.pop("a") - - assert value == 10 - assert "a" not in cow - - def test_setdefault_existing_key(self): - """Test setdefault() with an existing key.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - value = cow.setdefault("a", 10) - - assert value == 1 - assert cow["a"] == 1 - assert original == {"a": 1} - - def test_setdefault_new_key(self): - """Test setdefault() with a new key.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - value = cow.setdefault("b", 2) - - assert value == 2 - assert cow["b"] == 2 - assert original == {"a": 1} - - def test_setdefault_default_none(self): - """Test setdefault() with default None.""" - original = {"a": 1} - cow = CopyOnWriteDict(original) - - value = cow.setdefault("b") - - assert value is None - assert cow["b"] is None - - def test_clear(self): - """Test clear() method.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - - cow["d"] = 4 - cow.clear() - - assert len(cow) == 0 - assert list(cow.keys()) == [] - assert original == {"a": 1, "b": 2, "c": 3} - - def test_clear_empty_dict(self): - """Test clear() on an empty dict.""" - original = {} - cow = CopyOnWriteDict(original) - - cow.clear() - - assert len(cow) == 0 - - def test_update_after_clear(self): - """Test that update works after clear.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - cow.clear() - cow.update({"c": 3, "d": 4}) - - assert len(cow) == 2 - assert cow["c"] == 3 - assert cow["d"] == 4 - assert "a" not in cow - assert "b" not in cow - - def test_iter_modifications_before_original(self): - """Test that __iter__ yields modifications before original keys.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - # Modify existing key - cow["a"] = 10 - - keys = list(cow) - # Should have both keys, but modifications take precedence - assert set(keys) == {"a", "b"} - # First key should be from modifications (a), since we modified it - assert keys[0] == "a" - - def test_iter_skips_deleted_keys_in_modifications(self): - """Test that __iter__ skips keys that are in modifications but marked deleted.""" - original = {"a": 1, "b": 2} - cow = CopyOnWriteDict(original) - - # Modify a key (adds to modifications layer) - cow["a"] = 10 - # Add a new key (also in modifications layer) - cow["c"] = 3 - # Delete the modified key (marks as deleted but it's still in modifications layer) - del cow["a"] - - keys = list(cow) - # Should only have b (from original) and c (from modifications, not deleted) - assert set(keys) == {"b", "c"} - assert "a" not in keys - - def test_equality_with_empty_dict(self): - """CopyOnWriteDict with data should not equal empty dict.""" - cow = CopyOnWriteDict({"a": 1, "b": 2}) - assert cow != {} - assert {} != cow - assert not (cow == {}) - assert not ({} == cow) - - def test_equality_with_matching_dict(self): - """CopyOnWriteDict should equal dict with same key-value pairs.""" - original = {"a": 1, "b": 2, "c": 3} - cow = CopyOnWriteDict(original) - assert cow == {"a": 1, "b": 2, "c": 3} - assert {"a": 1, "b": 2, "c": 3} == cow - - def test_equality_with_different_dict(self): - """CopyOnWriteDict should not equal dict with different content.""" - cow = CopyOnWriteDict({"a": 1, "b": 2}) - assert cow != {"a": 1, "b": 3} - assert cow != {"a": 1} - assert cow != {"a": 1, "b": 2, "c": 3} - # Same length, different keys - assert cow != {"a": 1, "c": 2} - - def test_equality_after_modifications(self): - """Equality should reflect modifications.""" - cow = CopyOnWriteDict({"a": 1, "b": 2}) - cow["c"] = 3 - assert cow == {"a": 1, "b": 2, "c": 3} - assert cow != {"a": 1, "b": 2} - - def test_equality_after_deletions(self): - """Equality should reflect deletions.""" - cow = CopyOnWriteDict({"a": 1, "b": 2, "c": 3}) - del cow["b"] - assert cow == {"a": 1, "c": 3} - assert cow != {"a": 1, "b": 2, "c": 3} - - def test_equality_after_override(self): - """Equality should reflect overridden values.""" - cow = CopyOnWriteDict({"a": 1, "b": 2}) - cow["a"] = 10 - assert cow == {"a": 10, "b": 2} - assert cow != {"a": 1, "b": 2} - - def test_equality_with_another_copyonwritedict(self): - """Two CopyOnWriteDict instances with same content should be equal.""" - cow1 = CopyOnWriteDict({"a": 1, "b": 2}) - cow2 = CopyOnWriteDict({"a": 1, "b": 2}) - assert cow1 == cow2 - assert cow2 == cow1 - - def test_equality_empty_copyonwritedict(self): - """Empty CopyOnWriteDict should equal empty dict.""" - cow = CopyOnWriteDict({}) - assert cow == {} - assert {} == cow - - def test_equality_with_non_mapping_returns_notimplemented(self): - """Equality with non-Mapping types should return NotImplemented.""" - cow = CopyOnWriteDict({"a": 1}) - # These should not raise, Python will handle NotImplemented - assert cow != "not a dict" - assert cow != 123 - assert cow != ["a", "list"] - assert cow != None - - def test_inequality_operator(self): - """Test __ne__ operator works correctly.""" - cow = CopyOnWriteDict({"a": 1, "b": 2}) - assert cow != {} - assert cow != {"a": 1} - assert not (cow != {"a": 1, "b": 2}) - - def test_copyonwritedict_is_unhashable(self): - """CopyOnWriteDict should remain unhashable like dict.""" - cow = CopyOnWriteDict({"a": 1}) - with pytest.raises(TypeError): - hash(cow) - - def test_equality_wxo_args_scenario(self): - """Regression test for the WXO args bug scenario.""" - # This is the exact scenario from the bug report - cow = CopyOnWriteDict({ - "wxo_connection_id": "", - "wxo_auth": "fake-token", - "wxo_environment_id": "draft", - }) - - # These were the failing assertions in the bug - assert cow != {} - assert {} != cow - assert cow == { - "wxo_connection_id": "", - "wxo_auth": "fake-token", - "wxo_environment_id": "draft", - } - - -class TestCopyOnWriteFunction: - """Test suite for copyonwrite() factory function.""" - - def test_copyonwrite_with_dict(self): - """Test copyonwrite() function with a dictionary.""" - original = {"a": 1, "b": 2} - cow = copyonwrite(original) - - assert isinstance(cow, CopyOnWriteDict) - assert isinstance(cow, dict) - assert cow["a"] == 1 - assert cow["b"] == 2 - - def test_copyonwrite_returns_copyonwritedict(self): - """Test that copyonwrite() returns a CopyOnWriteDict instance.""" - original = {"x": 10} - result = copyonwrite(original) - - assert type(result).__name__ == "CopyOnWriteDict" - assert result["x"] == 10 - - def test_copyonwrite_with_empty_dict(self): - """Test copyonwrite() function with an empty dictionary.""" - original = {} - cow = copyonwrite(original) - - assert isinstance(cow, CopyOnWriteDict) - assert len(cow) == 0 - - def test_copyonwrite_preserves_original(self): - """Test that copyonwrite() doesn't modify the original dict.""" - original = {"a": 1} - cow = copyonwrite(original) - - cow["a"] = 10 - cow["b"] = 2 - - assert original == {"a": 1} - assert cow["a"] == 10 - - def test_copyonwrite_with_list(self): - """Test copyonwrite() function with a list.""" - original = [1, 2, 3] - cow = copyonwrite(original) - - assert isinstance(cow, CopyOnWriteList) - assert isinstance(cow, list) - assert list(cow) == [1, 2, 3] - - def test_copyonwrite_with_list_preserves_original(self): - """Test that copyonwrite() doesn't modify the original list.""" - original = [1, 2, 3] - cow = copyonwrite(original) - - cow[0] = 10 - cow.append(4) - - assert original == [1, 2, 3] - assert list(cow) == [10, 2, 3, 4] - - def test_copyonwrite_with_unsupported_raises_typeerror(self): - """Test that copyonwrite() raises TypeError for unsupported types.""" - with pytest.raises(TypeError, match="No copy-on-write wrapper available"): - copyonwrite("string") - - with pytest.raises(TypeError, match="No copy-on-write wrapper available"): - copyonwrite(42) - - with pytest.raises(TypeError, match="No copy-on-write wrapper available"): - copyonwrite({1, 2, 3}) - - with pytest.raises(TypeError, match="No copy-on-write wrapper available"): - copyonwrite(None) - - -class TestCopyOnWriteList: - """Test suite for CopyOnWriteList class.""" - - def test_is_list_subclass(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - assert isinstance(cow, list) - assert issubclass(CopyOnWriteList, list) - - def test_read_delegation(self): - """Read operations delegate to original without materializing.""" - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - assert cow[0] == 1 - assert cow[2] == 3 - assert len(cow) == 3 - assert list(cow) == [1, 2, 3] - assert 2 in cow - assert 99 not in cow - assert not cow.has_modifications() - - def test_read_with_slice(self): - original = [10, 20, 30, 40] - cow = CopyOnWriteList(original) - assert cow[1:3] == [20, 30] - assert not cow.has_modifications() - - def test_setitem_materializes(self): - """First write materializes the list and protects original.""" - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - cow[0] = 10 - - assert cow[0] == 10 - assert list(cow) == [10, 2, 3] - assert original == [1, 2, 3] - assert cow.has_modifications() - - def test_delitem(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - del cow[1] - - assert list(cow) == [1, 3] - assert original == [1, 2, 3] - - def test_append(self): - original = [1, 2] - cow = CopyOnWriteList(original) - - cow.append(3) - - assert list(cow) == [1, 2, 3] - assert original == [1, 2] - assert cow.has_modifications() - - def test_extend(self): - original = [1] - cow = CopyOnWriteList(original) - - cow.extend([2, 3]) - - assert list(cow) == [1, 2, 3] - assert original == [1] - - def test_insert(self): - original = [1, 3] - cow = CopyOnWriteList(original) - - cow.insert(1, 2) - - assert list(cow) == [1, 2, 3] - assert original == [1, 3] - - def test_remove(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - cow.remove(2) - - assert list(cow) == [1, 3] - assert original == [1, 2, 3] - - def test_pop(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - val = cow.pop() - - assert val == 3 - assert list(cow) == [1, 2] - assert original == [1, 2, 3] - - def test_pop_index(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - val = cow.pop(0) - - assert val == 1 - assert list(cow) == [2, 3] - - def test_clear(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - cow.clear() - - assert list(cow) == [] - assert len(cow) == 0 - assert original == [1, 2, 3] - - def test_sort(self): - original = [3, 1, 2] - cow = CopyOnWriteList(original) - - cow.sort() - - assert list(cow) == [1, 2, 3] - assert original == [3, 1, 2] - - def test_reverse(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - cow.reverse() - - assert list(cow) == [3, 2, 1] - assert original == [1, 2, 3] - - def test_has_modifications_false_initially(self): - cow = CopyOnWriteList([1, 2]) - assert not cow.has_modifications() - - def test_has_modifications_true_after_write(self): - cow = CopyOnWriteList([1, 2]) - cow.append(3) - assert cow.has_modifications() - - def test_copy(self): - original = [1, 2, 3] - cow = CopyOnWriteList(original) - copied = cow.copy() - assert copied == [1, 2, 3] - assert isinstance(copied, list) - assert not isinstance(copied, CopyOnWriteList) - - def test_repr(self): - cow = CopyOnWriteList([1, 2]) - assert "CopyOnWriteList" in repr(cow) - assert "1" in repr(cow) - - def test_empty_list(self): - original = [] - cow = CopyOnWriteList(original) - assert len(cow) == 0 - assert list(cow) == [] - - def test_multiple_writes(self): - """Multiple writes only materialize once.""" - original = [1, 2, 3] - cow = CopyOnWriteList(original) - - cow[0] = 10 - cow[1] = 20 - cow.append(4) - - assert list(cow) == [10, 20, 3, 4] - assert original == [1, 2, 3] - - -# --------------------------------------------------------------------------- -# Pydantic helpers for wrap_payload_for_isolation tests -# --------------------------------------------------------------------------- - - -class _FrozenPayload(BaseModel): - model_config = ConfigDict(frozen=True) - - name: str = "test" - args: dict = Field(default_factory=dict) - - -class _PayloadWithList(BaseModel): - model_config = ConfigDict(frozen=True) - - name: str = "test" - items: list = Field(default_factory=list) - - -class _PayloadWithNested(BaseModel): - model_config = ConfigDict(frozen=True) - - name: str = "test" - inner: _FrozenPayload = Field(default_factory=_FrozenPayload) - - -class _HeaderLike(RootModel[dict[str, str]]): - model_config = ConfigDict(frozen=True) - - -class _PayloadWithAny(BaseModel): - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - name: str = "test" - data: object = None - - -class TestWrapPayloadForIsolation: - """Tests for wrap_payload_for_isolation().""" - - def test_dict_field_wrapped(self): - """Dict fields are wrapped with CopyOnWriteDict.""" - original_args = {"key": "val"} - p = _FrozenPayload(name="x", args=original_args) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.args, CopyOnWriteDict) - assert wrapped.args["key"] == "val" - # Mutation on wrapped copy doesn't affect original - wrapped.args["key"] = "changed" - assert original_args["key"] == "val" - - def test_list_field_wrapped(self): - """List fields are wrapped with CopyOnWriteList.""" - original_items = [1, 2, 3] - p = _PayloadWithList(name="x", items=original_items) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.items, CopyOnWriteList) - assert list(wrapped.items) == [1, 2, 3] - wrapped.items.append(4) - assert original_items == [1, 2, 3] - - def test_nested_basemodel_recursively_wrapped(self): - """Nested BaseModel fields are recursively wrapped.""" - inner_args = {"nested": "data"} - inner = _FrozenPayload(name="inner", args=inner_args) - p = _PayloadWithNested(name="outer", inner=inner) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.inner.args, CopyOnWriteDict) - wrapped.inner.args["nested"] = "changed" - assert inner_args["nested"] == "data" - - def test_rootmodel_payload_wrapped(self): - """RootModel payloads have their .root dict wrapped.""" - original_root = {"Content-Type": "text/plain"} - p = _HeaderLike(root=original_root) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.root, CopyOnWriteDict) - assert wrapped.root["Content-Type"] == "text/plain" - wrapped.root["X-New"] = "header" - assert "X-New" not in original_root - - def test_primitives_shared(self): - """Primitive fields are not copied.""" - p = _FrozenPayload(name="hello", args={}) - - wrapped = wrap_payload_for_isolation(p) - - assert wrapped.name is p.name - - def test_none_fields_skipped(self): - """None-valued fields are not wrapped.""" - p = _PayloadWithAny(name="x", data=None) - - wrapped = wrap_payload_for_isolation(p) - - assert wrapped.data is None - - def test_any_typed_dict_field_wrapped(self): - """Any-typed fields that are dicts get wrapped.""" - original = {"a": 1} - p = _PayloadWithAny(name="x", data=original) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.data, CopyOnWriteDict) - wrapped.data["a"] = 99 - assert original["a"] == 1 - - def test_any_typed_list_field_wrapped(self): - """Any-typed fields that are lists get wrapped.""" - original = [1, 2, 3] - p = _PayloadWithAny(name="x", data=original) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.data, CopyOnWriteList) - wrapped.data[0] = 99 - assert original[0] == 1 - - def test_payload_with_no_mutable_fields_unchanged(self): - """Payload with only primitive fields returns same instance.""" - p = _FrozenPayload(name="x", args={}) - - wrapped = wrap_payload_for_isolation(p) - assert wrapped.name == "x" - assert wrapped.args == {} - - # Empty dict is not None, so it will be wrapped — test with no dict - p2 = _PayloadWithAny(name="x", data=42) - wrapped2 = wrap_payload_for_isolation(p2) - # data=42 is a primitive, name="x" is a primitive — no updates needed - assert wrapped2.name == "x" - assert wrapped2.data == 42 - - -# --------------------------------------------------------------------------- -# Helper: a target class for weakref proxies -# --------------------------------------------------------------------------- - - -class _Service: - """Dummy service object that supports weak references.""" - - def __init__(self, name: str = "svc"): - self.name = name - - def greet(self) -> str: - return f"hello from {self.name}" - - -class _PayloadWithWeakref(BaseModel): - """Payload with a weakref proxy field (non-writable by convention).""" - - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - label: str = "test" - service: object = None # Will hold a weakref.proxy - - -class _PayloadWithWeakrefAndDict(BaseModel): - """Payload with both a weakref proxy and a mutable dict field.""" - - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - label: str = "test" - service: object = None - args: dict = Field(default_factory=dict) - - -# --------------------------------------------------------------------------- -# Tests: weakref proxies through isolation -# --------------------------------------------------------------------------- - - -class TestWeakrefIsolation: - """Tests for weakref proxy handling in payload isolation.""" - - def test_wrap_value_passes_proxy_through(self): - """_wrap_value returns a weakref.proxy as-is (no copy).""" - svc = _Service("alpha") - proxy = weakref.proxy(svc) - - result = _wrap_value(proxy) - - assert result is proxy - assert result.greet() == "hello from alpha" - - def test_wrap_value_passes_callable_proxy_through(self): - """_wrap_value returns a callable weakref.proxy as-is.""" - svc = _Service("beta") - proxy = weakref.proxy(svc) # proxy to an object with __call__-able methods - - result = _wrap_value(proxy) - - assert result is proxy - - def test_payload_weakref_field_preserved_after_isolation(self): - """Weakref proxy field survives wrap_payload_for_isolation unchanged.""" - svc = _Service("gamma") - proxy = weakref.proxy(svc) - p = _PayloadWithWeakref(label="x", service=proxy) - - wrapped = wrap_payload_for_isolation(p) - - # The proxy is the same object — not copied - assert wrapped.service is proxy - assert wrapped.service.greet() == "hello from gamma" - - def test_payload_weakref_alongside_mutable_field(self): - """Weakref proxy is preserved while mutable dict field is CoW-wrapped.""" - svc = _Service("delta") - proxy = weakref.proxy(svc) - original_args = {"key": "val"} - p = _PayloadWithWeakrefAndDict(label="x", service=proxy, args=original_args) - - wrapped = wrap_payload_for_isolation(p) - - # Weakref proxy passed through - assert wrapped.service is proxy - assert wrapped.service.greet() == "hello from delta" - # Dict field is CoW-wrapped and isolates mutations - assert isinstance(wrapped.args, CopyOnWriteDict) - wrapped.args["key"] = "changed" - assert original_args["key"] == "val" - - def test_multiple_isolations_share_same_proxy(self): - """Multiple isolation calls return the same proxy identity.""" - svc = _Service("echo") - proxy = weakref.proxy(svc) - p = _PayloadWithWeakref(label="x", service=proxy) - - w1 = wrap_payload_for_isolation(p) - w2 = wrap_payload_for_isolation(p) - - assert w1.service is w2.service is proxy - - def test_weakref_in_dict_field_passed_through(self): - """A weakref proxy stored inside a dict value is not deep-copied.""" - svc = _Service("foxtrot") - proxy = weakref.proxy(svc) - original = {"svc": proxy, "count": 1} - p = _PayloadWithAny(name="x", data=original) - - wrapped = wrap_payload_for_isolation(p) - - # The dict itself is CoW-wrapped - assert isinstance(wrapped.data, CopyOnWriteDict) - # The proxy inside the dict is the same object (read from original) - assert wrapped.data["svc"] is proxy - assert wrapped.data["svc"].greet() == "hello from foxtrot" - - def test_weakref_in_list_field_passed_through(self): - """A weakref proxy stored inside a list element is not deep-copied.""" - svc = _Service("golf") - proxy = weakref.proxy(svc) - original = [proxy, 42] - p = _PayloadWithAny(name="x", data=original) - - wrapped = wrap_payload_for_isolation(p) - - assert isinstance(wrapped.data, CopyOnWriteList) - # Proxy is read from the original list (no copy) - assert wrapped.data[0] is proxy - assert wrapped.data[0].greet() == "hello from golf" - - def test_expired_weakref_raises_on_access(self): - """If the referent is garbage-collected, accessing the proxy raises ReferenceError.""" - svc = _Service("hotel") - proxy = weakref.proxy(svc) - p = _PayloadWithWeakref(label="x", service=proxy) - - wrapped = wrap_payload_for_isolation(p) - - # Delete the referent - del svc - with pytest.raises(ReferenceError): - wrapped.service.greet() - - -# --------------------------------------------------------------------------- -# Tests: _safe_deepcopy diagnostics -# --------------------------------------------------------------------------- - - -class TestSafeDeepCopy: - """Tests for _safe_deepcopy error handling.""" - - def test_copyable_value_returned(self): - """Normal objects are deep-copied successfully.""" - original = {"a": [1, 2, 3]} - result = _safe_deepcopy(original) - - assert result == original - assert result is not original - assert result["a"] is not original["a"] - - def test_non_copyable_returns_shared_reference(self): - """Non-copyable objects return a shared reference with a warning.""" - import threading - - lock = threading.Lock() - - result = _safe_deepcopy(lock) - - # Should return the original object as a shared reference - assert result is lock - - def test_non_copyable_logs_warning(self, caplog): - """Non-copyable objects log a warning when falling back to shared reference.""" - import logging - import threading - - lock = threading.Lock() - - with caplog.at_level(logging.WARNING): - result = _safe_deepcopy(lock) - - assert result is lock - assert "Cannot deep-copy" in caplog.text or "sharing reference" in caplog.text - - -# --------------------------------------------------------------------------- -# Tests: BaseException isolation (no deepcopy) -# --------------------------------------------------------------------------- - - -class _NonCopyableError(Exception): - """Exception whose __init__ uses keyword-only args, breaking deepcopy.""" - - def __init__(self, *, message: str = "error", request: object = None): - super().__init__(message) - self.request = request - - -class _PayloadWithException(BaseModel): - """Payload carrying an exception field (like GenerationErrorPayload).""" - - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - name: str = "test" - exception: BaseException - - -class _PayloadWithExceptionAndDict(BaseModel): - """Payload with both an exception and a mutable dict field.""" - - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - name: str = "test" - exception: BaseException - args: dict = Field(default_factory=dict) - - -class TestExceptionIsolation: - """Tests for BaseException handling in payload isolation.""" - - def test_wrap_value_returns_exception_as_is(self): - """_wrap_value returns a BaseException instance without copying.""" - exc = ValueError("something went wrong") - result = _wrap_value(exc) - assert result is exc - - def test_wrap_value_returns_non_copyable_exception_as_is(self): - """_wrap_value returns exceptions with keyword-only __init__ as-is.""" - exc = _NonCopyableError(message="conn error", request=None) - result = _wrap_value(exc) - assert result is exc - - def test_wrap_payload_exception_field_shared(self): - """Exception field in a payload is shared by reference after isolation.""" - exc = _NonCopyableError(message="conn error", request=None) - p = _PayloadWithException(name="x", exception=exc) - - wrapped = wrap_payload_for_isolation(p) - - assert wrapped.exception is exc - - def test_wrap_payload_exception_no_warning(self, caplog): - """Isolating a payload with an exception field produces no deepcopy warning.""" - import logging - - exc = _NonCopyableError(message="conn error", request=None) - p = _PayloadWithException(name="x", exception=exc) - - with caplog.at_level(logging.WARNING): - wrap_payload_for_isolation(p) - - assert "Cannot deep-copy" not in caplog.text - - def test_wrap_payload_exception_alongside_dict(self): - """Exception is shared while dict field is CoW-wrapped.""" - exc = _NonCopyableError(message="conn error", request=None) - original_args = {"key": "val"} - p = _PayloadWithExceptionAndDict( - name="x", exception=exc, args=original_args - ) - - wrapped = wrap_payload_for_isolation(p) - - assert wrapped.exception is exc - assert isinstance(wrapped.args, CopyOnWriteDict) - wrapped.args["key"] = "changed" - assert original_args["key"] == "val" - - def test_wrap_value_base_exception_subclass(self): - """_wrap_value handles BaseException subclasses (not just Exception).""" - exc = KeyboardInterrupt() - result = _wrap_value(exc) - assert result is exc - - def test_wrap_value_standard_exception(self): - """_wrap_value handles standard copyable exceptions as-is too.""" - exc = RuntimeError("boom") - result = _wrap_value(exc) - assert result is exc diff --git a/tests/unit/cpex/framework/test_models_http_fields.py b/tests/unit/cpex/framework/test_models_http_fields.py deleted file mode 100644 index e72dd499..00000000 --- a/tests/unit/cpex/framework/test_models_http_fields.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_models_http_fields.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Tests for HTTP-transport fields backported from ContextForge main: - - PluginViolation.http_status_code - - PluginViolation.http_headers - - PluginResult.http_headers -""" - -from cpex.framework.models import PluginResult, PluginViolation - - -def test_plugin_violation_http_fields_default_none(): - violation = PluginViolation(reason="r", description="d", code="C", details={}) - assert violation.http_status_code is None - assert violation.http_headers is None - - -def test_plugin_violation_http_fields_set(): - violation = PluginViolation( - reason="rate-limited", - description="too many requests", - code="RATE_LIMITED", - details={}, - http_status_code=429, - http_headers={"Retry-After": "30"}, - ) - assert violation.http_status_code == 429 - assert violation.http_headers == {"Retry-After": "30"} - - -def test_plugin_violation_http_fields_serialization_roundtrip(): - original = PluginViolation( - reason="r", - description="d", - code="C", - details={}, - http_status_code=422, - http_headers={"X-Trace-Id": "abc"}, - ) - restored = PluginViolation.model_validate(original.model_dump()) - assert restored.http_status_code == 422 - assert restored.http_headers == {"X-Trace-Id": "abc"} - - -def test_plugin_result_http_headers_default_none(): - result = PluginResult() - assert result.http_headers is None - - -def test_plugin_result_http_headers_set_and_roundtrip(): - result = PluginResult(http_headers={"X-RateLimit-Remaining": "0"}) - assert result.http_headers == {"X-RateLimit-Remaining": "0"} - restored = PluginResult.model_validate(result.model_dump()) - assert restored.http_headers == {"X-RateLimit-Remaining": "0"} diff --git a/tests/unit/cpex/framework/test_models_package_version.py b/tests/unit/cpex/framework/test_models_package_version.py deleted file mode 100644 index ce59dd59..00000000 --- a/tests/unit/cpex/framework/test_models_package_version.py +++ /dev/null @@ -1,371 +0,0 @@ -# -*- coding: utf-8 -*- -"""Additional unit tests for PluginPackageInfo and PluginVersionRegistry in cpex.framework.models. - -This module provides additional test coverage for edge cases and scenarios -not covered in the main test_plugin_models.py file. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import PluginPackageInfo, PluginVersionInfo, PluginVersionRegistry - - -class TestPluginPackageInfoEdgeCases: - """Additional edge case tests for PluginPackageInfo.""" - - def test_pypi_package_single_character(self): - """Single character PyPI package names should be valid.""" - pkg = PluginPackageInfo(pypi_package="a") - assert pkg.pypi_package == "a" - - def test_pypi_package_two_characters(self): - """Two character PyPI package names should be valid.""" - pkg = PluginPackageInfo(pypi_package="ab") - assert pkg.pypi_package == "ab" - - def test_pypi_package_max_length(self): - """PyPI package name at exactly 214 characters should be valid.""" - max_name = "a" * 214 - pkg = PluginPackageInfo(pypi_package=max_name) - assert pkg.pypi_package == max_name - assert len(pkg.pypi_package) == 214 - - def test_pypi_package_with_numbers_only(self): - """PyPI package names with only numbers should be valid.""" - pkg = PluginPackageInfo(pypi_package="123") - assert pkg.pypi_package == "123" - - def test_pypi_package_mixed_separators(self): - """PyPI package names with mixed valid separators should be valid.""" - pkg = PluginPackageInfo(pypi_package="my-package_name.version") - assert pkg.pypi_package == "my-package_name.version" - - def test_git_repository_without_git_extension(self): - """Git repository URLs without .git extension should be valid.""" - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo") - assert pkg.git_repository == "https://github.com/user/repo" - - def test_git_repository_with_subdirectories(self): - """Git repository URLs with subdirectories should be valid.""" - pkg = PluginPackageInfo(git_repository="https://github.com/org/team/repo.git") - assert pkg.git_repository == "https://github.com/org/team/repo.git" - - def test_git_repository_ssh_with_port(self): - """SSH Git URLs with custom ports are not supported by the current validator.""" - # The current regex doesn't support ssh:// protocol with ports - with pytest.raises(ValueError, match="Invalid Git repository URL"): - PluginPackageInfo(git_repository="ssh://git@github.com:2222/user/repo.git") - - def test_git_branch_single_character(self): - """Single character branch names should be valid.""" - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="v") - assert pkg.git_branch_tag_commit == "v" - - def test_git_branch_with_multiple_slashes(self): - """Branch names with multiple slashes should be valid.""" - pkg = PluginPackageInfo( - git_repository="https://github.com/user/repo.git", git_branch_tag_commit="feature/sub/branch" - ) - assert pkg.git_branch_tag_commit == "feature/sub/branch" - - def test_git_commit_short_hash(self): - """Short commit hashes (7 characters) should be valid.""" - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="abc1234") - assert pkg.git_branch_tag_commit == "abc1234" - - def test_git_commit_full_hash(self): - """Full commit hashes (40 characters) should be valid.""" - full_hash = "a" * 40 - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit=full_hash) - assert pkg.git_branch_tag_commit == full_hash - - def test_version_constraint_with_spaces(self): - """Version constraints with spaces around operators should be valid.""" - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">= 1.0.0, < 2.0.0") - assert pkg.version_constraint == ">= 1.0.0, < 2.0.0" - - def test_version_constraint_triple_equals(self): - """Version constraints with === operator should be valid.""" - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="===1.0.0") - assert pkg.version_constraint == "===1.0.0" - - def test_version_constraint_with_local_version(self): - """Version constraints with local version identifiers are not supported by current validator.""" - # The current regex doesn't support + in version constraints - with pytest.raises(ValueError, match="Invalid version constraint"): - PluginPackageInfo(pypi_package="my-package", version_constraint="==1.0.0+local.version") - - def test_both_installation_methods_with_all_fields(self): - """Both installation methods with all optional fields should be valid.""" - pkg = PluginPackageInfo( - pypi_package="my-package", - git_repository="https://github.com/user/repo.git", - git_branch_tag_commit="v1.0.0", - version_constraint=">=1.0.0,<2.0.0", - ) - assert pkg.pypi_package == "my-package" - assert pkg.git_repository == "https://github.com/user/repo.git" - assert pkg.git_branch_tag_commit == "v1.0.0" - assert pkg.version_constraint == ">=1.0.0,<2.0.0" - - -class TestPluginVersionInfoEdgeCases: - """Additional edge case tests for PluginVersionInfo.""" - - def test_version_info_minimal_fields(self): - """PluginVersionInfo with only required fields should be valid.""" - info = PluginVersionInfo(version="1.0.0", released="2024-01-01", manifest_file="manifest.json") - assert info.version == "1.0.0" - assert info.released == "2024-01-01" - assert info.manifest_file == "manifest.json" - assert info.breaking_changes is None - assert info.deprecated is False - assert info.changelog is None - - def test_version_info_all_fields(self): - """PluginVersionInfo with all fields should be valid.""" - info = PluginVersionInfo( - version="2.0.0", - released="2024-02-01", - breaking_changes=True, - deprecated=True, - manifest_file="manifest.json", - changelog="Major update with breaking changes", - min_max_framework_version="0.2.0,0.3.0", - ) - assert info.version == "2.0.0" - assert info.breaking_changes is True - assert info.deprecated is True - assert info.changelog == "Major update with breaking changes" - assert info.min_max_framework_version == "0.2.0,0.3.0" - - def test_version_info_prerelease_version(self): - """PluginVersionInfo with pre-release version should be valid.""" - info = PluginVersionInfo(version="1.0.0-alpha.1", released="2024-01-01", manifest_file="manifest.json") - assert info.version == "1.0.0-alpha.1" - - def test_version_info_dev_version(self): - """PluginVersionInfo with dev version should be valid.""" - info = PluginVersionInfo( - version="1.0.0.dev1", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0.dev1,0.1.0.dev10", - ) - assert info.version == "1.0.0.dev1" - - -class TestPluginVersionRegistryEdgeCases: - """Additional edge case tests for PluginVersionRegistry.""" - - def test_registry_with_only_prerelease(self): - """Registry with only pre-release versions should work correctly.""" - v1 = PluginVersionInfo( - version="1.0.0-alpha", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=None, latest_prerelease=v1, versions=[v1]) - - assert registry.get_version() is None - assert registry.latest_prerelease == v1 - - def test_registry_with_both_latest_and_prerelease(self): - """Registry with both latest and latest_prerelease should maintain both.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v2 = PluginVersionInfo( - version="1.1.0-beta", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v1, latest_prerelease=v2, versions=[v1, v2]) - - assert registry.get_version() == v1 - assert registry.latest_prerelease == v2 - - def test_get_latest_compatible_with_single_version_in_range(self): - """get_latest_compatible with only one version in range should return it.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - result = registry.get_latest_compatible("0.1.5") - assert result == v1 - - def test_get_latest_compatible_with_overlapping_ranges(self): - """get_latest_compatible with overlapping version ranges should return latest.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.3.0", - ) - v2 = PluginVersionInfo( - version="1.5.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.2.0,0.4.0", - ) - v3 = PluginVersionInfo( - version="2.0.0", - released="2024-03-01", - manifest_file="manifest.json", - min_max_framework_version="0.2.5,0.5.0", - ) - - registry = PluginVersionRegistry(latest=v3, versions=[v1, v2, v3]) - - # Framework 0.2.7 matches v1, v2, and v3 - should return v3 (latest) - result = registry.get_latest_compatible("0.2.7") - assert result == v3 - assert result.version == "2.0.0" - - def test_get_latest_compatible_with_non_overlapping_ranges(self): - """get_latest_compatible with non-overlapping ranges should return correct version.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v2 = PluginVersionInfo( - version="2.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.3.0,0.4.0", - ) - - registry = PluginVersionRegistry(latest=v2, versions=[v1, v2]) - - # Framework 0.1.5 should match v1 - result = registry.get_latest_compatible("0.1.5") - assert result == v1 - - # Framework 0.3.5 should match v2 - result = registry.get_latest_compatible("0.3.5") - assert result == v2 - - # Framework 0.2.5 should match neither - result = registry.get_latest_compatible("0.2.5") - assert result is None - - def test_get_latest_compatible_with_malformed_version_in_list(self): - """get_latest_compatible should handle malformed versions in the list gracefully.""" - v1 = PluginVersionInfo( - version="not-a-version", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v2 = PluginVersionInfo( - version="1.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v2, versions=[v1, v2]) - - # Should still find v2 even though v1 has invalid version - result = registry.get_latest_compatible("0.1.5") - # If sorting fails, it returns the first compatible version - assert result in [v1, v2] - - def test_get_latest_compatible_with_extra_whitespace_in_min_max(self): - """get_latest_compatible should handle extra whitespace in min_max_framework_version.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version=" 0.1.0 , 0.2.0 ", - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - result = registry.get_latest_compatible("0.1.5") - assert result == v1 - - def test_get_latest_compatible_with_three_part_min_max(self): - """get_latest_compatible should reject min_max with more than 2 parts.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0,0.3.0", # Invalid: 3 parts - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - result = registry.get_latest_compatible("0.1.5") - assert result is None - - def test_get_latest_compatible_with_reversed_min_max(self): - """get_latest_compatible should handle reversed min/max (max < min).""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.2.0,0.1.0", # Reversed - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - # No version should match since max < min - result = registry.get_latest_compatible("0.1.5") - assert result is None - - def test_registry_versions_list_order_independence(self): - """Registry should work correctly regardless of versions list order.""" - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v2 = PluginVersionInfo( - version="2.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v3 = PluginVersionInfo( - version="1.5.0", - released="2024-01-15", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - # Test with different orderings - registry1 = PluginVersionRegistry(latest=v2, versions=[v1, v2, v3]) - - registry2 = PluginVersionRegistry(latest=v2, versions=[v3, v1, v2]) - - registry3 = PluginVersionRegistry(latest=v2, versions=[v2, v3, v1]) - - # All should return v2 as the latest compatible - result1 = registry1.get_latest_compatible("0.1.5") - result2 = registry2.get_latest_compatible("0.1.5") - result3 = registry3.get_latest_compatible("0.1.5") - - assert result1 == v2 - assert result2 == v2 - assert result3 == v2 - - -# Made with Bob diff --git a/tests/unit/cpex/framework/test_models_tls.py b/tests/unit/cpex/framework/test_models_tls.py deleted file mode 100644 index da1e3a82..00000000 --- a/tests/unit/cpex/framework/test_models_tls.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for TLS configuration on external MCP plugins.""" - -# Standard -from pathlib import Path - -# Third-Party -import pytest - -# First-Party -from cpex.framework.models import MCPClientTLSConfig, PluginConfig - - -def _write_pem(path: Path) -> str: - path.write_text( - "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIJALICEFAKE000MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV\nBAMMCXRlc3QtY2EwHhcNMjUwMTAxMDAwMDAwWhcNMjYwMTAxMDAwMDAwWjAUMRIw\nEAYDVQQDDAl0ZXN0LWNsaTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nALzM8FSo48ByKC16ecEsPpRghr7kDDLOZWisS+8mHb4RLzdrg5e8tRgFuBlbslUT\n8VE+j54v+J2mOv5u18CVeq4xjp1IqP/PpeL9Z8sY2XohGKVCUj8lMiMM6trXwPh3\n4nDXwG8hxhTZWOeAZv93FqMgBANpUAOC0yM5Ar+uSoC2Tbf3juDEnHiVNWdP6hJg\n38zrla9Yh+SPYj9m6z6wG6jZc37SaJnKI/v4ycq31wkK7S226gRA7i72H+eEt1Kp\nI5rkJ+6kkfgeJc8FvbB6c88T9EycneEW7Pm2Xp6gJdxeN1g2jeDJPnWc5Cj9VPYU\nCJPwy6DnKSmGA4MZij19+cUCAwEAAaNQME4wHQYDVR0OBBYEFL0CyJXw5CtP6Ls9\nVgn8BxwysA2fMB8GA1UdIwQYMBaAFL0CyJXw5CtP6Ls9Vgn8BxwysA2fMAwGA1Ud\nEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIgUjACmJS4cGL7yp0T1vpuZi856\nG7k18Om8Ze9fJbVI1MBBxDWS5F9bNOn5z1ytgCMs9VXg7QibQPXlqprcM2aYJWaV\ndHZ92ohqzJ0EB1G2r8x5Fkw3O0mEWcJvl10FgUVHVGzi552MZGFMZ7DAMA4EAq/u\nsOUgWup8uLSyvvl7dao3rJ8k+YkBWkDu6eCKwQn3nNKFB5Bg9P6IKkmDdLhYodl/\nW1q/qmHZapCp8XDsrmS8skWsmcFJFU6f4VDOwdJaNiMgRGQpWlwO4dRw9xvyhsHc\nsOf0HWNvw60sX6Zav8HC0FzDGhGJkpyyU10BzpQLVEf5AEE7MkK5eeqi2+0=\n-----END CERTIFICATE-----\n", - encoding="utf-8", - ) - return str(path) - - -@pytest.mark.parametrize( - "verify", - [True, False], -) -def test_plugin_config_supports_tls_block(tmp_path, verify): - ca_path = Path(tmp_path) / "ca.crt" - client_bundle = Path(tmp_path) / "client.pem" - _write_pem(ca_path) - _write_pem(client_bundle) - - config = PluginConfig( - name="ExternalTLSPlugin", - kind="external", - hooks=["prompt_pre_fetch"], - mcp={ - "proto": "STREAMABLEHTTP", - "url": "https://plugins.internal.example.com/mcp", - "tls": { - "ca_bundle": str(ca_path), - "certfile": str(client_bundle), - "verify": verify, - }, - }, - ) - - assert config.mcp is not None - assert config.mcp.tls is not None - assert config.mcp.tls.certfile == str(client_bundle) - assert config.mcp.tls.verify == verify - - -def test_plugin_config_tls_missing_cert_raises(tmp_path): - ca_path = Path(tmp_path) / "ca.crt" - _write_pem(ca_path) - - with pytest.raises(ValueError): - PluginConfig( - name="ExternalTLSPlugin", - kind="external", - hooks=["prompt_pre_fetch"], - mcp={ - "proto": "STREAMABLEHTTP", - "url": "https://plugins.internal.example.com/mcp", - "tls": { - "keyfile": str(ca_path), - }, - }, - ) - - -def test_plugin_config_tls_missing_file(tmp_path): - missing_path = Path(tmp_path) / "missing.crt" - - with pytest.raises(ValueError): - PluginConfig( - name="ExternalTLSPlugin", - kind="external", - hooks=["prompt_pre_fetch"], - mcp={ - "proto": "STREAMABLEHTTP", - "url": "https://plugins.internal.example.com/mcp", - "tls": { - "ca_bundle": str(missing_path), - }, - }, - ) - - -def test_tls_config_from_env_defaults(monkeypatch, tmp_path): - ca_path = Path(tmp_path) / "ca.crt" - client_cert = Path(tmp_path) / "client.pem" - _write_pem(ca_path) - _write_pem(client_cert) - - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CA_BUNDLE", str(ca_path)) - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CERTFILE", str(client_cert)) - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_VERIFY", "true") - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CHECK_HOSTNAME", "true") - - tls_config = MCPClientTLSConfig.from_env() - - assert tls_config is not None - assert tls_config.ca_bundle == str(ca_path) - assert tls_config.certfile == str(client_cert) - assert tls_config.verify is True - assert tls_config.check_hostname is True - - -def test_tls_config_from_env_returns_none(monkeypatch): - monkeypatch.delenv("PLUGINS_MTLS_CA_BUNDLE", raising=False) - monkeypatch.delenv("PLUGINS_MTLS_CLIENT_CERT", raising=False) - monkeypatch.delenv("PLUGINS_MTLS_CLIENT_KEY", raising=False) - monkeypatch.delenv("PLUGINS_MTLS_CLIENT_KEY_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_MTLS_VERIFY", raising=False) - monkeypatch.delenv("PLUGINS_MTLS_CHECK_HOSTNAME", raising=False) - - assert MCPClientTLSConfig.from_env() is None diff --git a/tests/unit/cpex/framework/test_models_user_context.py b/tests/unit/cpex/framework/test_models_user_context.py deleted file mode 100644 index 5e4522e2..00000000 --- a/tests/unit/cpex/framework/test_models_user_context.py +++ /dev/null @@ -1,130 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_models_user_context.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Tests for UserContext model and its propagation through GlobalContext / PluginContext. -The model and field shape mirror ContextForge main (#3152) so plugins coded against -the gateway's `context.user_context.X` API keep working unchanged. -""" - -from datetime import datetime, timezone - -from cpex.framework import GlobalContext, PluginContext, UserContext - - -def test_user_context_defaults(): - uc = UserContext(user_id="alice@example.com") - assert uc.user_id == "alice@example.com" - assert uc.email is None - assert uc.full_name is None - assert uc.is_admin is False - assert uc.groups == [] - assert uc.roles == [] - assert uc.team_id is None - assert uc.teams is None - assert uc.department is None - assert uc.attributes == {} - assert uc.auth_method is None - assert uc.authenticated_at is None - assert uc.service_account is None - assert uc.delegation_chain == [] - - -def test_user_context_full_construction(): - ts = datetime(2026, 4, 30, 12, 0, 0, tzinfo=timezone.utc) - uc = UserContext( - user_id="bob@example.com", - email="bob@example.com", - full_name="Bob Builder", - is_admin=True, - groups=["eng", "sec"], - roles=["admin"], - team_id="t1", - teams=["t1", "t2"], - department="Platform", - attributes={"locale": "en-US"}, - auth_method="bearer", - authenticated_at=ts, - service_account="svc-deploy", - delegation_chain=["alice@example.com"], - ) - assert uc.is_admin is True - assert uc.auth_method == "bearer" - assert uc.authenticated_at == ts - assert uc.delegation_chain == ["alice@example.com"] - - -def test_user_context_serialization_roundtrip(): - ts = datetime(2026, 4, 30, 12, 0, 0, tzinfo=timezone.utc) - original = UserContext( - user_id="carol@example.com", - email="carol@example.com", - is_admin=False, - groups=["eng"], - attributes={"k": "v"}, - authenticated_at=ts, - delegation_chain=["alice@example.com", "bob@example.com"], - ) - restored = UserContext.model_validate(original.model_dump()) - assert restored == original - - -def test_global_context_user_context_default_none(): - gc = GlobalContext(request_id="r1") - assert gc.user_context is None - - -def test_global_context_user_context_set(): - uc = UserContext(user_id="alice@example.com") - gc = GlobalContext(request_id="r1", user_context=uc) - assert gc.user_context is uc - assert gc.user_context.user_id == "alice@example.com" - - -def test_plugin_context_user_context_property_proxies_global(): - uc = UserContext(user_id="alice@example.com", is_admin=True) - pc = PluginContext(global_context=GlobalContext(request_id="r1", user_context=uc)) - # Property must return the same instance held on the global context. - assert pc.user_context is pc.global_context.user_context - assert pc.user_context is uc - assert pc.user_context.is_admin is True - - -def test_plugin_context_user_context_property_returns_none_when_unset(): - pc = PluginContext(global_context=GlobalContext(request_id="r1")) - assert pc.user_context is None - - -def test_plugin_context_user_email_from_user_context(): - uc = UserContext(user_id="alice@example.com", email="alice@example.com") - pc = PluginContext(global_context=GlobalContext(request_id="r1", user_context=uc)) - assert pc.user_email == "alice@example.com" - - -def test_plugin_context_user_email_falls_back_to_legacy_string(): - pc = PluginContext(global_context=GlobalContext(request_id="r1", user="bob@example.com")) - assert pc.user_email == "bob@example.com" - - -def test_plugin_context_user_email_falls_back_to_legacy_dict(): - pc = PluginContext( - global_context=GlobalContext(request_id="r1", user={"email": "carol@example.com", "name": "Carol"}) - ) - assert pc.user_email == "carol@example.com" - - -def test_plugin_context_user_email_none_when_no_identity(): - pc = PluginContext(global_context=GlobalContext(request_id="r1")) - assert pc.user_email is None - - -def test_plugin_context_user_groups_from_user_context(): - uc = UserContext(user_id="alice@example.com", groups=["eng", "sec"]) - pc = PluginContext(global_context=GlobalContext(request_id="r1", user_context=uc)) - assert pc.user_groups == ["eng", "sec"] - - -def test_plugin_context_user_groups_empty_when_no_user_context(): - pc = PluginContext(global_context=GlobalContext(request_id="r1")) - assert pc.user_groups == [] diff --git a/tests/unit/cpex/framework/test_observability.py b/tests/unit/cpex/framework/test_observability.py deleted file mode 100644 index 9f491349..00000000 --- a/tests/unit/cpex/framework/test_observability.py +++ /dev/null @@ -1,388 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_observability.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests for observability dependency injection in the plugin framework. -Verifies that a mock ObservabilityProvider can be injected into PluginManager -and that start_span/end_span are called during plugin execution. -""" - -# Standard -from typing import Any, Dict, List, Optional, Tuple -from unittest.mock import patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework import ( - GlobalContext, - Plugin, - PluginConfig, - PluginManager, - PluginResult, - PromptHookType, - PromptPrehookPayload, -) -from cpex.framework.base import HookRef -from cpex.framework.manager import PluginExecutor -from cpex.framework.observability import NullObservability, current_trace_id -from cpex.framework.registry import PluginRef - - -class RecordingObservability: - """Mock observability provider that records all calls for assertions.""" - - def __init__(self): - self.spans: List[Tuple[str, dict]] = [] - self.ended_spans: List[Tuple[Optional[str], str, Optional[Dict[str, Any]]]] = [] - - def start_span( - self, - trace_id: str, - name: str, - kind: str = "internal", - resource_type: Optional[str] = None, - resource_name: Optional[str] = None, - attributes: Optional[Dict[str, Any]] = None, - ) -> Optional[str]: - span_id = f"span-{len(self.spans)}" - self.spans.append( - ( - span_id, - { - "trace_id": trace_id, - "name": name, - "kind": kind, - "resource_type": resource_type, - "resource_name": resource_name, - "attributes": attributes, - }, - ) - ) - return span_id - - def end_span( - self, - span_id: Optional[str], - status: str = "ok", - attributes: Optional[Dict[str, Any]] = None, - ) -> None: - self.ended_spans.append((span_id, status, attributes)) - - -class SimplePlugin(Plugin): - """A minimal plugin that modifies the payload.""" - - async def prompt_pre_fetch(self, payload, context): - payload.args["traced"] = "yes" - return PluginResult(continue_processing=True, modified_payload=payload) - - -class BlockingPlugin(Plugin): - """A plugin that blocks the pipeline with a violation.""" - - async def prompt_pre_fetch(self, payload, context): - from cpex.framework.models import PluginViolation - - return PluginResult( - continue_processing=False, - violation=PluginViolation(code="BLOCKED", reason="test block", description="blocked for testing"), - ) - - -@pytest.mark.asyncio -async def test_observability_injection_via_plugin_manager(): - """Test that an ObservabilityProvider injected into PluginManager is invoked during hook execution.""" - recorder = RecordingObservability() - trace_id = "test-trace-001" - - manager = PluginManager( - "./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml", - observability=recorder, - ) - await manager.initialize() - - config = PluginConfig( - name="TracedPlugin", - description="Plugin for observability test", - author="Test", - version="1.0", - tags=["test"], - kind="TracedPlugin", - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin = SimplePlugin(config) - - token = current_trace_id.set(trace_id) - try: - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="req-1") - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - payload, - global_context=global_context, - ) - - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.args["traced"] == "yes" - - # Verify start_span was called: hook-chain span + per-plugin span - assert len(recorder.spans) == 2 - - # First span: hook-chain span - chain_span_id, chain_info = recorder.spans[0] - assert chain_info["trace_id"] == trace_id - assert chain_info["name"] == "plugin.hook.invoke" - assert chain_info["kind"] == "internal" - assert chain_info["attributes"]["plugin.hook.type"] == PromptHookType.PROMPT_PRE_FETCH - assert chain_info["attributes"]["plugin.chain.length"] == 1 - - # Second span: per-plugin execution span - plugin_span_id, plugin_info = recorder.spans[1] - assert plugin_info["trace_id"] == trace_id - assert "TracedPlugin" in plugin_info["name"] - assert plugin_info["kind"] == "internal" - assert plugin_info["resource_type"] == "plugin" - assert plugin_info["resource_name"] == "TracedPlugin" - assert plugin_info["attributes"]["plugin.name"] == "TracedPlugin" - - # Verify end_span was called for both spans - assert len(recorder.ended_spans) == 2 - - # Per-plugin span ended first - ended_plugin_id, plugin_status, plugin_end_attrs = recorder.ended_spans[0] - assert ended_plugin_id == plugin_span_id - assert plugin_status == "ok" - assert plugin_end_attrs["plugin.had_violation"] is False - assert plugin_end_attrs["plugin.modified_payload"] is True - - # Hook-chain span ended second - ended_chain_id, chain_status, chain_end_attrs = recorder.ended_spans[1] - assert ended_chain_id == chain_span_id - assert chain_status == "ok" - assert chain_end_attrs["plugin.executed_count"] == 1 - assert chain_end_attrs["plugin.skipped_count"] == 0 - assert chain_end_attrs["plugin.chain.stopped"] is False - finally: - current_trace_id.reset(token) - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_no_tracing_without_trace_id(): - """Test that observability is not invoked when no trace_id is set.""" - recorder = RecordingObservability() - - manager = PluginManager( - "./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml", - observability=recorder, - ) - await manager.initialize() - - config = PluginConfig( - name="UntracedPlugin", - description="Plugin without trace", - author="Test", - version="1.0", - tags=["test"], - kind="UntracedPlugin", - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin = SimplePlugin(config) - - # Do NOT set current_trace_id — it should default to None - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="req-2") - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - payload, - global_context=global_context, - ) - - assert result.continue_processing - # No spans should have been created - assert len(recorder.spans) == 0 - assert len(recorder.ended_spans) == 0 - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_no_tracing_without_provider(): - """Test that plugin execution works when no observability provider is injected.""" - manager = PluginManager( - "./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml", - observability=None, - ) - await manager.initialize() - - config = PluginConfig( - name="NoProviderPlugin", - description="Plugin without observability", - author="Test", - version="1.0", - tags=["test"], - kind="NoProviderPlugin", - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin = SimplePlugin(config) - - token = current_trace_id.set("trace-no-provider") - try: - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="req-3") - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - payload, - global_context=global_context, - ) - - # Plugin should still execute normally - assert result.continue_processing - assert result.modified_payload is not None - assert result.modified_payload.args["traced"] == "yes" - finally: - current_trace_id.reset(token) - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_null_observability_default(): - """Test NullObservability no-op implementation.""" - null_obs = NullObservability() - - # start_span returns None - span_id = null_obs.start_span(trace_id="t1", name="test_span") - assert span_id is None - - # end_span does nothing (no error) - null_obs.end_span(span_id=None, status="ok") - null_obs.end_span(span_id="some-span", status="error", attributes={"key": "val"}) - - -@pytest.mark.asyncio -async def test_executor_observability_injection(): - """Test that PluginExecutor correctly receives and uses an observability provider.""" - recorder = RecordingObservability() - executor = PluginExecutor(observability=recorder) - - assert executor.observability is recorder - - # Also verify default is None - default_executor = PluginExecutor() - assert default_executor.observability is None - - -def test_protocol_method_bodies(): - """Verify that calling Protocol method stubs directly returns None.""" - # First-Party - from cpex.framework.observability import ObservabilityProvider - - # Call the unbound protocol methods directly to exercise the `...` bodies - result1 = ObservabilityProvider.start_span(None, trace_id="t", name="n") - assert result1 is None - - result2 = ObservabilityProvider.end_span(None, span_id=None) - assert result2 is None - - -def test_get_plugin_manager_creates_manager_when_enabled(): - """Test that get_plugin_manager creates a PluginManager when plugins_enabled is True.""" - # First-Party - import cpex.framework as fw - - recorder = RecordingObservability() - - # Reset the module-level singleton - original = fw._plugin_manager - fw._plugin_manager = None - try: - with patch("cpex.framework.settings.settings") as mock_settings: - mock_settings.enabled = True - mock_settings.config_file = "./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml" - pm = fw.get_plugin_manager(observability=recorder) - - assert pm is not None - assert isinstance(pm, PluginManager) - finally: - fw._plugin_manager = original - PluginManager.reset() - - -@pytest.mark.asyncio -async def test_hook_chain_span_records_stopped_by_on_halt(): - """Test that hook-chain span records stopped_by when pipeline is halted.""" - recorder = RecordingObservability() - trace_id = "test-trace-halt" - - manager = PluginManager( - "./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml", - observability=recorder, - ) - await manager.initialize() - - config = PluginConfig( - name="BlockingPlugin", - description="Blocks the pipeline", - author="Test", - version="1.0", - tags=["test"], - kind="BlockingPlugin", - hooks=["prompt_pre_fetch"], - config={}, - ) - plugin = BlockingPlugin(config) - - token = current_trace_id.set(trace_id) - try: - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - mock_get.return_value = [hook_ref] - - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="req-halt") - - result, _ = await manager.invoke_hook( - PromptHookType.PROMPT_PRE_FETCH, - payload, - global_context=global_context, - ) - - assert not result.continue_processing - assert result.violation is not None - - # Verify hook-chain span records the halt - assert len(recorder.ended_spans) == 2 # per-plugin + hook-chain - - # Hook-chain span is the last ended span - ended_chain_id, chain_status, chain_end_attrs = recorder.ended_spans[-1] - assert chain_status == "ok" - assert chain_end_attrs["plugin.chain.stopped"] is True - assert chain_end_attrs["plugin.chain.stopped_by"] == "BlockingPlugin" - assert chain_end_attrs["plugin.executed_count"] == 1 - assert chain_end_attrs["plugin.skipped_count"] == 0 - finally: - current_trace_id.reset(token) - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_plugin_base.py b/tests/unit/cpex/framework/test_plugin_base.py deleted file mode 100644 index e802ef7d..00000000 --- a/tests/unit/cpex/framework/test_plugin_base.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for cpex.framework.base.""" - -# Standard -from unittest.mock import MagicMock - -# Third-Party -import pytest -from pydantic import BaseModel - -# First-Party -from cpex.framework.base import Plugin -from cpex.framework.errors import PluginError -from cpex.framework.hooks import registry as hook_registry -from cpex.framework.models import PluginConfig, PluginResult - - -class DummyPayload(BaseModel): - value: int - - -def _config() -> PluginConfig: - return PluginConfig( - name="test_plugin", - description="test", - author="tester", - kind="test.Plugin", - version="1.0.0", - hooks=["hook"], - tags=["tag"], - ) - - -def test_json_to_payload_uses_instance_mapping(): - plugin = Plugin(_config(), hook_payloads={"hook": DummyPayload}) - payload = plugin.json_to_payload("hook", {"value": 7}) - assert isinstance(payload, DummyPayload) - assert payload.value == 7 - - -def test_json_to_payload_uses_registry(monkeypatch): - plugin = Plugin(_config()) - registry = MagicMock() - registry.get_payload_type.return_value = DummyPayload - monkeypatch.setattr(hook_registry, "get_hook_registry", lambda: registry) - - payload = plugin.json_to_payload("hook", '{"value": 3}') - assert isinstance(payload, DummyPayload) - assert payload.value == 3 - - -def test_json_to_payload_missing_type(monkeypatch): - plugin = Plugin(_config()) - registry = MagicMock() - registry.get_payload_type.return_value = None - monkeypatch.setattr(hook_registry, "get_hook_registry", lambda: registry) - - with pytest.raises(PluginError): - plugin.json_to_payload("missing", {"value": 1}) - - -def test_json_to_result_uses_instance_mapping(): - plugin = Plugin(_config(), hook_results={"hook": PluginResult}) - result = plugin.json_to_result("hook", {"continue_processing": False}) - assert isinstance(result, PluginResult) - assert result.continue_processing is False - - -def test_json_to_result_uses_registry(monkeypatch): - plugin = Plugin(_config()) - registry = MagicMock() - registry.get_result_type.return_value = PluginResult - monkeypatch.setattr(hook_registry, "get_hook_registry", lambda: registry) - - result = plugin.json_to_result("hook", '{"continue_processing": true}') - assert isinstance(result, PluginResult) - assert result.continue_processing is True - - -def test_json_to_result_missing_type(monkeypatch): - plugin = Plugin(_config()) - registry = MagicMock() - registry.get_result_type.return_value = None - monkeypatch.setattr(hook_registry, "get_hook_registry", lambda: registry) - - with pytest.raises(PluginError): - plugin.json_to_result("missing", {"continue_processing": True}) diff --git a/tests/unit/cpex/framework/test_plugin_base_coverage.py b/tests/unit/cpex/framework/test_plugin_base_coverage.py deleted file mode 100644 index 090ea4b9..00000000 --- a/tests/unit/cpex/framework/test_plugin_base_coverage.py +++ /dev/null @@ -1,296 +0,0 @@ -# -*- coding: utf-8 -*- -"""Coverage tests for cpex.framework.base — PluginRef and HookRef.""" - -# Standard -from unittest.mock import MagicMock, patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.base import HookRef, Plugin, PluginRef -from cpex.framework.decorator import hook -from cpex.framework.errors import PluginError -from cpex.framework.models import PluginCondition, PluginConfig, PluginContext, PluginMode, PluginPayload, PluginResult - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_config(**overrides) -> PluginConfig: - defaults = dict( - name="test_plugin", - description="test", - author="tester", - kind="test.Plugin", - version="1.0.0", - hooks=["tool_pre_invoke"], - tags=["tag1", "tag2"], - mode=PluginMode.CONCURRENT, - priority=42, - conditions=[PluginCondition(server_ids={"s1"})], - ) - defaults.update(overrides) - return PluginConfig(**defaults) - - -class ConcretePlugin(Plugin): - """A concrete plugin with a convention-based hook method.""" - - async def tool_pre_invoke(self, payload: PluginPayload, context: PluginContext) -> PluginResult: - return PluginResult(continue_processing=True) - - -class DecoratedPlugin(Plugin): - """Plugin that uses the @hook decorator for registration.""" - - @hook("tool_pre_invoke") - async def my_custom_method(self, payload: PluginPayload, context: PluginContext) -> PluginResult: - return PluginResult(continue_processing=True) - - -class SyncPlugin(Plugin): - """Plugin with a synchronous hook method (invalid).""" - - def tool_pre_invoke(self, payload: PluginPayload, context: PluginContext) -> PluginResult: - return PluginResult(continue_processing=True) - - -class BadSigPlugin(Plugin): - """Plugin with wrong parameter count (invalid).""" - - async def tool_pre_invoke(self, payload: PluginPayload) -> PluginResult: - return PluginResult(continue_processing=True) - - -class ThreeParamPlugin(Plugin): - """Plugin with 3 parameters (accepts extensions).""" - - async def tool_pre_invoke(self, payload: PluginPayload, context: PluginContext, extensions) -> PluginResult: - return PluginResult(continue_processing=True) - - -class NoHookPlugin(Plugin): - """Plugin with no method matching the hook.""" - - pass - - -# =========================================================================== -# PluginRef tests -# =========================================================================== - - -class TestPluginRef: - def test_uuid_is_32_hex(self): - ref = PluginRef(ConcretePlugin(_make_config())) - assert len(ref.uuid) == 32 - assert all(c in "0123456789abcdef" for c in ref.uuid) - - def test_uuid_unique(self): - plugin = ConcretePlugin(_make_config()) - ref1 = PluginRef(plugin) - ref2 = PluginRef(plugin) - assert ref1.uuid != ref2.uuid - - def test_priority(self): - ref = PluginRef(ConcretePlugin(_make_config(priority=7))) - assert ref.priority == 7 - - def test_name(self): - ref = PluginRef(ConcretePlugin(_make_config(name="my_plugin"))) - assert ref.name == "my_plugin" - - def test_hooks(self): - ref = PluginRef(ConcretePlugin(_make_config(hooks=["hook_a", "hook_b"]))) - assert ref.hooks == ["hook_a", "hook_b"] - - def test_tags(self): - ref = PluginRef(ConcretePlugin(_make_config(tags=["t1", "t2"]))) - assert ref.tags == ["t1", "t2"] - - def test_conditions(self): - cond = PluginCondition(tools={"calc"}) - ref = PluginRef(ConcretePlugin(_make_config(conditions=[cond]))) - assert ref.conditions == [cond] - - def test_conditions_none(self): - ref = PluginRef(ConcretePlugin(_make_config(conditions=[]))) - assert ref.conditions == [] - - def test_mode(self): - ref = PluginRef(ConcretePlugin(_make_config(mode=PluginMode.AUDIT))) - assert ref.mode == PluginMode.AUDIT - - def test_plugin_property(self): - plugin = ConcretePlugin(_make_config()) - ref = PluginRef(plugin) - assert ref.plugin is plugin - - -# =========================================================================== -# HookRef tests -# =========================================================================== - - -class TestHookRef: - def test_convention_based_discovery(self): - plugin = ConcretePlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("tool_pre_invoke", ref) - assert hook_ref.hook is not None - assert hook_ref.name == "tool_pre_invoke" - assert hook_ref.plugin_ref is ref - - def test_decorator_based_discovery(self): - plugin = DecoratedPlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("tool_pre_invoke", ref) - assert hook_ref.hook is not None - assert hook_ref.name == "tool_pre_invoke" - - def test_missing_method_raises(self): - plugin = NoHookPlugin(_make_config()) - ref = PluginRef(plugin) - with pytest.raises(PluginError, match="has no hook"): - HookRef("tool_pre_invoke", ref) - - def test_wrong_param_count_raises(self): - plugin = BadSigPlugin(_make_config()) - ref = PluginRef(plugin) - with pytest.raises(PluginError, match="invalid signature"): - HookRef("tool_pre_invoke", ref) - - def test_three_param_plugin_accepted(self): - plugin = ThreeParamPlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("tool_pre_invoke", ref) - assert hook_ref.hook is not None - assert hook_ref.accepts_extensions is True - - def test_two_param_plugin_no_extensions(self): - plugin = ConcretePlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("tool_pre_invoke", ref) - assert hook_ref.accepts_extensions is False - - def test_sync_method_raises(self): - plugin = SyncPlugin(_make_config()) - ref = PluginRef(plugin) - with pytest.raises(PluginError, match="must be async"): - HookRef("tool_pre_invoke", ref) - - def test_properties(self): - plugin = ConcretePlugin(_make_config()) - ref = PluginRef(plugin) - hook_ref = HookRef("tool_pre_invoke", ref) - assert hook_ref.name == "tool_pre_invoke" - assert hook_ref.plugin_ref is ref - assert callable(hook_ref.hook) - - -# =========================================================================== -# _validate_type_hints tests (called directly since it's commented out in __init__) -# =========================================================================== - - -class TestValidateTypeHints: - def _make_hook_ref(self, plugin_cls): - plugin = plugin_cls(_make_config()) - ref = PluginRef(plugin) - return HookRef("tool_pre_invoke", ref) - - def test_no_registry_types_skips(self): - hook_ref = self._make_hook_ref(ConcretePlugin) - import inspect - - func = hook_ref.hook - params = list(inspect.signature(func).parameters.values()) - registry = MagicMock() - registry.get_payload_type.return_value = None - registry.get_result_type.return_value = None - with patch("cpex.framework.hooks.registry.get_hook_registry", return_value=registry): - hook_ref._validate_type_hints("tool_pre_invoke", func, params, "test_plugin") - - def test_get_type_hints_exception(self): - hook_ref = self._make_hook_ref(ConcretePlugin) - import inspect - - func = hook_ref.hook - params = list(inspect.signature(func).parameters.values()) - registry = MagicMock() - registry.get_payload_type.return_value = PluginPayload - registry.get_result_type.return_value = PluginResult - with ( - patch("cpex.framework.hooks.registry.get_hook_registry", return_value=registry), - patch("typing.get_type_hints", side_effect=Exception("fail")), - ): - hook_ref._validate_type_hints("tool_pre_invoke", func, params, "test_plugin") - - def test_missing_payload_hint_raises(self): - hook_ref = self._make_hook_ref(ConcretePlugin) - import inspect - - func = hook_ref.hook - params = list(inspect.signature(func).parameters.values()) - registry = MagicMock() - registry.get_payload_type.return_value = PluginPayload - registry.get_result_type.return_value = PluginResult - with ( - patch("cpex.framework.hooks.registry.get_hook_registry", return_value=registry), - patch("typing.get_type_hints", return_value={"return": PluginResult}), - ): - with pytest.raises(PluginError, match="missing type hint"): - hook_ref._validate_type_hints("tool_pre_invoke", func, params, "test_plugin") - - def test_wrong_payload_type_raises(self): - hook_ref = self._make_hook_ref(ConcretePlugin) - import inspect - - func = hook_ref.hook - params = list(inspect.signature(func).parameters.values()) - param_name = params[0].name - registry = MagicMock() - registry.get_payload_type.return_value = PluginPayload - registry.get_result_type.return_value = PluginResult - with ( - patch("cpex.framework.hooks.registry.get_hook_registry", return_value=registry), - patch("typing.get_type_hints", return_value={param_name: str, "return": PluginResult}), - ): - with pytest.raises(PluginError, match="incorrect type hint"): - hook_ref._validate_type_hints("tool_pre_invoke", func, params, "test_plugin") - - def test_missing_return_hint_raises(self): - hook_ref = self._make_hook_ref(ConcretePlugin) - import inspect - - func = hook_ref.hook - params = list(inspect.signature(func).parameters.values()) - param_name = params[0].name - registry = MagicMock() - registry.get_payload_type.return_value = PluginPayload - registry.get_result_type.return_value = PluginResult - with ( - patch("cpex.framework.hooks.registry.get_hook_registry", return_value=registry), - patch("typing.get_type_hints", return_value={param_name: PluginPayload}), - ): - with pytest.raises(PluginError, match="missing return type hint"): - hook_ref._validate_type_hints("tool_pre_invoke", func, params, "test_plugin") - - def test_wrong_return_type_raises(self): - hook_ref = self._make_hook_ref(ConcretePlugin) - import inspect - - func = hook_ref.hook - params = list(inspect.signature(func).parameters.values()) - param_name = params[0].name - registry = MagicMock() - registry.get_payload_type.return_value = PluginPayload - registry.get_result_type.return_value = PluginResult - with ( - patch("cpex.framework.hooks.registry.get_hook_registry", return_value=registry), - patch("typing.get_type_hints", return_value={param_name: PluginPayload, "return": str}), - ): - with pytest.raises(PluginError, match="incorrect return type hint"): - hook_ref._validate_type_hints("tool_pre_invoke", func, params, "test_plugin") diff --git a/tests/unit/cpex/framework/test_plugin_models.py b/tests/unit/cpex/framework/test_plugin_models.py deleted file mode 100644 index 47daff05..00000000 --- a/tests/unit/cpex/framework/test_plugin_models.py +++ /dev/null @@ -1,830 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for cpex.framework.models.""" - -# Standard -import os -from pathlib import Path - -# Third-Party -import pytest - -# First-Party -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE -from cpex.framework.models import ( - MCPClientConfig, - MCPClientTLSConfig, - MCPServerConfig, - MCPServerTLSConfig, - PluginConfig, - TransportType, -) - - -def _write_file(tmp_path: Path, name: str) -> str: - file_path = tmp_path / name - file_path.write_text("data") - return str(file_path) - - -def test_bool_parsing_via_settings(monkeypatch): - """Bool fields on PluginsSettings handle true/false strings correctly.""" - # First-Party - from cpex.framework.settings import PluginsSettings - - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_VERIFY", "true") - assert PluginsSettings().client_mtls_verify is True - - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_VERIFY", "0") - assert PluginsSettings().client_mtls_verify is False - - -def test_client_tls_from_env(monkeypatch, tmp_path): - cert = _write_file(tmp_path, "client-cert.pem") - key = _write_file(tmp_path, "client-key.pem") - ca = _write_file(tmp_path, "client-ca.pem") - - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CERTFILE", cert) - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_KEYFILE", key) - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CA_BUNDLE", ca) - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_KEYFILE_PASSWORD", "pw") - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_VERIFY", "false") - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CHECK_HOSTNAME", "0") - - config = MCPClientTLSConfig.from_env() - assert config is not None - assert config.certfile == os.path.expanduser(cert) - assert config.keyfile == os.path.expanduser(key) - assert config.ca_bundle == os.path.expanduser(ca) - assert config.verify is False - assert config.check_hostname is False - - -def test_server_tls_from_env_invalid_cert_reqs(monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_SSL_CERT_REQS", "not-an-int") - with pytest.raises(ValueError): - MCPServerTLSConfig.from_env() - - -@pytest.mark.parametrize("uds_value", [""]) -def test_server_config_uds_validation_errors(uds_value): - with pytest.raises(ValueError): - MCPServerConfig(uds=uds_value) - - -def test_server_config_uds_missing_parent(tmp_path): - missing = tmp_path / "missing" / "sock" - with pytest.raises(ValueError): - MCPServerConfig(uds=str(missing)) - - -def test_server_config_uds_valid(tmp_path): - uds_path = tmp_path / "socket.sock" - config = MCPServerConfig(uds=str(uds_path)) - assert config.uds == str(uds_path.resolve()) - - -def test_server_config_tls_with_uds_raises(tmp_path): - uds_path = tmp_path / "socket.sock" - tls = MCPServerTLSConfig() - with pytest.raises(ValueError): - MCPServerConfig(uds=str(uds_path), tls=tls) - - -def test_server_config_from_env_invalid_port(monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_PORT", "bad") - with pytest.raises(ValueError): - MCPServerConfig.from_env() - - -def test_server_config_from_env_with_tls(monkeypatch, tmp_path): - cert = _write_file(tmp_path, "server-cert.pem") - key = _write_file(tmp_path, "server-key.pem") - monkeypatch.setenv("PLUGINS_SERVER_HOST", "0.0.0.0") - monkeypatch.setenv("PLUGINS_SERVER_PORT", "9000") - monkeypatch.setenv("PLUGINS_SERVER_SSL_ENABLED", "true") - monkeypatch.setenv("PLUGINS_SERVER_SSL_CERTFILE", cert) - monkeypatch.setenv("PLUGINS_SERVER_SSL_KEYFILE", key) - - config = MCPServerConfig.from_env() - assert config is not None - assert config.host == "0.0.0.0" - assert config.port == 9000 - assert config.tls is not None - - -def test_client_config_script_requires_executable(tmp_path): - script = tmp_path / "script.txt" - script.write_text("data") - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STDIO, script=str(script)) - - -@pytest.mark.parametrize("cmd_value", [[], [""], [" ", "x"]]) -def test_client_config_cmd_validation(cmd_value): - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STDIO, cmd=cmd_value) - - -@pytest.mark.parametrize("env_value", [{}, {"KEY": 1}]) -def test_client_config_env_validation(env_value): - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STDIO, cmd=["python"], env=env_value) - - -def test_client_config_cwd_validation(tmp_path): - missing = tmp_path / "missing" - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STDIO, cmd=["python"], cwd=str(missing)) - - -@pytest.mark.parametrize("uds_value", [""]) -def test_client_config_uds_validation_errors(uds_value): - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STREAMABLEHTTP, uds=uds_value) - - -def test_client_config_uds_missing_parent(tmp_path): - missing = tmp_path / "missing" / "sock" - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STREAMABLEHTTP, uds=str(missing)) - - -def test_client_config_tls_usage_errors(tmp_path): - tls = MCPClientTLSConfig() - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STDIO, cmd=["python"], tls=tls) - - uds_path = tmp_path / "socket.sock" - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STREAMABLEHTTP, uds=str(uds_path), tls=tls) - - -def test_client_config_transport_field_errors(): - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.STDIO, url="https://example.com", cmd=["python"]) - - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.SSE, script="script.py") - - with pytest.raises(ValueError): - MCPClientConfig(proto=TransportType.SSE, uds="/tmp/socket.sock") - - -def test_plugin_config_stdio_requires_script_or_cmd(): - mcp = MCPClientConfig(proto=TransportType.STDIO, cmd=None) - with pytest.raises(ValueError): - PluginConfig(name="plug", kind="internal", mcp=mcp) - - -def test_plugin_config_stdio_script_and_cmd_conflict(): - mcp = MCPClientConfig(proto=TransportType.STDIO, script="script.py", cmd=["python"]) - with pytest.raises(ValueError): - PluginConfig(name="plug", kind="internal", mcp=mcp) - - -def test_plugin_config_http_requires_url(): - mcp = MCPClientConfig(proto=TransportType.SSE) - with pytest.raises(ValueError): - PluginConfig(name="plug", kind="internal", mcp=mcp) - - -def test_plugin_config_external_requires_mcp(): - with pytest.raises(ValueError): - PluginConfig(name="external", kind=EXTERNAL_PLUGIN_TYPE) - - -def test_plugin_config_external_config_disallowed(): - mcp = MCPClientConfig(proto=TransportType.SSE, url="https://example.com") - with pytest.raises(ValueError): - PluginConfig(name="external", kind=EXTERNAL_PLUGIN_TYPE, config={"x": 1}, mcp=mcp) - - -# ============================================================================= -# PluginPackageInfo Validator Tests -# ============================================================================= - - -class TestPluginPackageInfoValidators: - """Tests for PluginPackageInfo field validators.""" - - # ------------------------------------------------------------------------- - # PyPI Package Validator Tests - # ------------------------------------------------------------------------- - - def test_pypi_package_valid(self): - """Valid PyPI package names should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - # Standard package names - pkg = PluginPackageInfo(pypi_package="my-package") - assert pkg.pypi_package == "my-package" - - pkg = PluginPackageInfo(pypi_package="my_package") - assert pkg.pypi_package == "my_package" - - pkg = PluginPackageInfo(pypi_package="my.package") - assert pkg.pypi_package == "my.package" - - pkg = PluginPackageInfo(pypi_package="MyPackage123") - assert pkg.pypi_package == "MyPackage123" - - # Complex valid names - pkg = PluginPackageInfo(pypi_package="apex-pii-filter") - assert pkg.pypi_package == "apex-pii-filter" - - pkg = PluginPackageInfo(pypi_package="package_name.with-everything123") - assert pkg.pypi_package == "package_name.with-everything123" - - def test_pypi_package_invalid_empty(self): - """Empty or whitespace-only PyPI package names should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - # Empty string is treated as None, so model validator catches it - with pytest.raises(ValueError, match="At least one installation method"): - PluginPackageInfo(pypi_package="") - - with pytest.raises(ValueError, match="cannot be empty or whitespace"): - PluginPackageInfo(pypi_package=" ") - - def test_pypi_package_invalid_start_end(self): - """PyPI package names starting/ending with invalid characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package="-invalid") - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package="invalid-") - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package=".invalid") - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package="invalid.") - - def test_pypi_package_invalid_characters(self): - """PyPI package names with invalid characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package="my package") - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package="my@package") - - with pytest.raises(ValueError, match="Invalid PyPI package name"): - PluginPackageInfo(pypi_package="my/package") - - def test_pypi_package_too_long(self): - """PyPI package names exceeding 214 characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - long_name = "a" * 215 - with pytest.raises(ValueError, match="exceeds maximum length of 214 characters"): - PluginPackageInfo(pypi_package=long_name) - - def test_pypi_package_none_allowed(self): - """None should be allowed for pypi_package when git_repository is provided.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") - assert pkg.pypi_package is None - - # ------------------------------------------------------------------------- - # Git Repository Validator Tests - # ------------------------------------------------------------------------- - - def test_git_repository_valid_https(self): - """Valid HTTPS Git repository URLs should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") - assert pkg.git_repository == "https://github.com/user/repo.git" - - pkg = PluginPackageInfo(git_repository="https://gitlab.com/user/repo.git") - assert pkg.git_repository == "https://gitlab.com/user/repo.git" - - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo") - assert pkg.git_repository == "https://github.com/user/repo" - - def test_git_repository_valid_http(self): - """Valid HTTP Git repository URLs should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="http://example.com/user/repo.git") - assert pkg.git_repository == "http://example.com/user/repo.git" - - def test_git_repository_valid_git_protocol(self): - """Valid git:// protocol URLs should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="git://github.com/user/repo.git") - assert pkg.git_repository == "git://github.com/user/repo.git" - - def test_git_repository_valid_ssh(self): - """Valid SSH Git repository URLs should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="git@github.com:user/repo.git") - assert pkg.git_repository == "git@github.com:user/repo.git" - - def test_git_repository_invalid_empty(self): - """Empty or whitespace-only Git repository URLs should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - # Empty string is treated as None, so model validator catches it - with pytest.raises(ValueError, match="At least one installation method"): - PluginPackageInfo(git_repository="") - - with pytest.raises(ValueError, match="cannot be empty or whitespace"): - PluginPackageInfo(git_repository=" ") - - def test_git_repository_invalid_format(self): - """Invalid Git repository URL formats should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="Invalid Git repository URL"): - PluginPackageInfo(git_repository="not-a-valid-url") - - with pytest.raises(ValueError, match="Invalid Git repository URL"): - PluginPackageInfo(git_repository="ftp://example.com/repo.git") - - def test_git_repository_none_allowed(self): - """None should be allowed for git_repository when pypi_package is provided.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package") - assert pkg.git_repository is None - - # ------------------------------------------------------------------------- - # Git Branch/Tag/Commit Validator Tests - # ------------------------------------------------------------------------- - - def test_git_branch_tag_commit_valid(self): - """Valid Git branch/tag/commit references should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - # Branch names - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="main") - assert pkg.git_branch_tag_commit == "main" - - pkg = PluginPackageInfo( - git_repository="https://github.com/user/repo.git", git_branch_tag_commit="feature/new-feature" - ) - assert pkg.git_branch_tag_commit == "feature/new-feature" - - # Tag names - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="v1.0.0") - assert pkg.git_branch_tag_commit == "v1.0.0" - - # Commit hashes - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="abc123def456") - assert pkg.git_branch_tag_commit == "abc123def456" - - pkg = PluginPackageInfo( - git_repository="https://github.com/user/repo.git", - git_branch_tag_commit="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", - ) - assert pkg.git_branch_tag_commit == "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" - - def test_git_branch_tag_commit_invalid_empty(self): - """Empty or whitespace-only Git references should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - # Empty string is treated as None, which is valid - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="") - assert pkg.git_branch_tag_commit is None - - with pytest.raises(ValueError, match="cannot be empty or whitespace"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit=" ") - - def test_git_branch_tag_commit_invalid_characters(self): - """Git references with invalid characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="Invalid Git branch/tag/commit"): - PluginPackageInfo( - git_repository="https://github.com/user/repo.git", git_branch_tag_commit="branch with spaces" - ) - - with pytest.raises(ValueError, match="Invalid Git branch/tag/commit"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="branch@invalid") - - def test_git_branch_tag_commit_invalid_start_end(self): - """Git references with invalid start/end characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="Cannot start with"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="/invalid") - - with pytest.raises(ValueError, match="Cannot start with"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit=".invalid") - - with pytest.raises(ValueError, match="Cannot start with"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="-invalid") - - with pytest.raises(ValueError, match="end with"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="invalid/") - - with pytest.raises(ValueError, match="end with"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit="invalid.") - - def test_git_branch_tag_commit_too_long(self): - """Git references exceeding 255 characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - long_ref = "a" * 256 - with pytest.raises(ValueError, match="exceeds maximum length of 255 characters"): - PluginPackageInfo(git_repository="https://github.com/user/repo.git", git_branch_tag_commit=long_ref) - - def test_git_branch_tag_commit_none_allowed(self): - """None should be allowed for git_branch_tag_commit.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") - assert pkg.git_branch_tag_commit is None - - # ------------------------------------------------------------------------- - # Version Constraint Validator Tests - # ------------------------------------------------------------------------- - - def test_version_constraint_valid_single(self): - """Valid single version constraints should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") - assert pkg.version_constraint == ">=1.0.0" - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="==1.2.3") - assert pkg.version_constraint == "==1.2.3" - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="~=1.2.3") - assert pkg.version_constraint == "~=1.2.3" - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="<2.0.0") - assert pkg.version_constraint == "<2.0.0" - - def test_version_constraint_valid_multiple(self): - """Valid multiple version constraints should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0,<2.0.0") - assert pkg.version_constraint == ">=1.0.0,<2.0.0" - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0, <2.0.0, !=1.5.0") - assert pkg.version_constraint == ">=1.0.0, <2.0.0, !=1.5.0" - - def test_version_constraint_valid_with_prerelease(self): - """Version constraints with pre-release identifiers should be accepted.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0-alpha") - assert pkg.version_constraint == ">=1.0.0-alpha" - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="==1.0.0rc1") - assert pkg.version_constraint == "==1.0.0rc1" - - def test_version_constraint_invalid_empty(self): - """Empty or whitespace-only version constraints should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - # Empty string is treated as None, which is valid - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="") - assert pkg.version_constraint is None - - with pytest.raises(ValueError, match="cannot be empty or whitespace"): - PluginPackageInfo(pypi_package="my-package", version_constraint=" ") - - def test_version_constraint_invalid_format(self): - """Invalid version constraint formats should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="Invalid version constraint"): - PluginPackageInfo(pypi_package="my-package", version_constraint="invalid") - - with pytest.raises(ValueError, match="Invalid version constraint"): - PluginPackageInfo(pypi_package="my-package", version_constraint="1.0.0") - - def test_version_constraint_invalid_empty_parts(self): - """Version constraints with empty parts should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="cannot contain empty parts"): - PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0,,") - - def test_version_constraint_too_long(self): - """Version constraints exceeding 255 characters should be rejected.""" - from cpex.framework.models import PluginPackageInfo - - long_constraint = ">=1.0.0," + ",".join([f"!={i}.0.0" for i in range(100)]) - with pytest.raises(ValueError, match="exceeds maximum length of 255 characters"): - PluginPackageInfo(pypi_package="my-package", version_constraint=long_constraint) - - def test_version_constraint_none_allowed(self): - """None should be allowed for version_constraint.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package") - assert pkg.version_constraint is None - - # ------------------------------------------------------------------------- - # Model Validator Tests - # ------------------------------------------------------------------------- - - def test_installation_method_required(self): - """At least one installation method must be specified.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="At least one installation method must be specified"): - PluginPackageInfo() - - def test_installation_method_pypi_only(self): - """PyPI package alone should be valid.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package") - assert pkg.pypi_package == "my-package" - assert pkg.git_repository is None - - def test_installation_method_git_only(self): - """Git repository alone should be valid.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") - assert pkg.git_repository == "https://github.com/user/repo.git" - assert pkg.pypi_package is None - - def test_installation_method_both_allowed(self): - """Both PyPI package and Git repository can be specified.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package", git_repository="https://github.com/user/repo.git") - assert pkg.pypi_package == "my-package" - assert pkg.git_repository == "https://github.com/user/repo.git" - - def test_git_branch_requires_repository(self): - """git_branch_tag_commit requires git_repository.""" - from cpex.framework.models import PluginPackageInfo - - with pytest.raises(ValueError, match="can only be specified when 'git_repository' is provided"): - PluginPackageInfo(pypi_package="my-package", git_branch_tag_commit="main") - - def test_complete_git_installation(self): - """Complete Git installation with all fields should be valid.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo( - git_repository="https://github.com/user/repo.git", - git_branch_tag_commit="v1.0.0", - version_constraint=">=1.0.0", - ) - assert pkg.git_repository == "https://github.com/user/repo.git" - assert pkg.git_branch_tag_commit == "v1.0.0" - assert pkg.version_constraint == ">=1.0.0" - - def test_complete_pypi_installation(self): - """Complete PyPI installation with version constraint should be valid.""" - from cpex.framework.models import PluginPackageInfo - - pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0,<2.0.0") - assert pkg.pypi_package == "my-package" - assert pkg.version_constraint == ">=1.0.0,<2.0.0" - - -# ============================================================================= -# PluginVersionRegistry Tests -# ============================================================================= - - -class TestPluginVersionRegistry: - """Tests for PluginVersionRegistry class.""" - - def test_get_version_returns_latest(self): - """get_version should return the latest version.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - latest_version = PluginVersionInfo( - version="2.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=latest_version, versions=[latest_version]) - - assert registry.get_version() == latest_version - - def test_get_version_returns_none_when_no_latest(self): - """get_version should return None when latest is not set.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - version = PluginVersionInfo(version="1.0.0", released="2024-01-01", manifest_file="manifest.json") - - registry = PluginVersionRegistry(latest=None, versions=[version]) - - assert registry.get_version() is None - - def test_get_latest_compatible_finds_compatible_version(self): - """get_latest_compatible should find a version compatible with the framework version.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.1.5", - ) - v2 = PluginVersionInfo( - version="2.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.5,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v2, versions=[v1, v2]) - - # Framework version 0.1.3 should match v1 - result = registry.get_latest_compatible("0.1.3") - assert result == v1 - - # Framework version 0.1.8 should match v2 - result = registry.get_latest_compatible("0.1.8") - assert result == v2 - - def test_get_latest_compatible_returns_latest_when_multiple_match(self): - """get_latest_compatible should return the latest version when multiple versions match.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v2 = PluginVersionInfo( - version="1.5.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - v3 = PluginVersionInfo( - version="2.0.0", - released="2024-03-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v3, versions=[v1, v2, v3]) - - # All versions support 0.1.5, should return the latest (v3) - result = registry.get_latest_compatible("0.1.5") - assert result == v3 - assert result.version == "2.0.0" - - def test_get_latest_compatible_returns_none_when_no_match(self): - """get_latest_compatible should return None when no version is compatible.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.1.5", - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - # Framework version 0.2.0 is outside the range - result = registry.get_latest_compatible("0.2.0") - assert result is None - - def test_get_latest_compatible_handles_invalid_framework_version(self): - """get_latest_compatible should return None for invalid framework version.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - # Invalid version format - result = registry.get_latest_compatible("not-a-version") - assert result is None - - def test_get_latest_compatible_skips_versions_without_min_max(self): - """get_latest_compatible should skip versions without min_max_framework_version.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", released="2024-01-01", manifest_file="manifest.json", min_max_framework_version=None - ) - v2 = PluginVersionInfo( - version="2.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v2, versions=[v1, v2]) - - # Should only find v2 since v1 has no min_max_framework_version - result = registry.get_latest_compatible("0.1.5") - assert result == v2 - - def test_get_latest_compatible_handles_malformed_min_max(self): - """get_latest_compatible should skip versions with malformed min_max_framework_version.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0", # Missing max version - ) - v2 = PluginVersionInfo( - version="2.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="invalid,version", # Invalid versions - ) - v3 = PluginVersionInfo( - version="3.0.0", - released="2024-03-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", # Valid - ) - - registry = PluginVersionRegistry(latest=v3, versions=[v1, v2, v3]) - - # Should only find v3 - result = registry.get_latest_compatible("0.1.5") - assert result == v3 - - def test_get_latest_compatible_with_prerelease_versions(self): - """get_latest_compatible should handle pre-release versions correctly.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0rc1", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0.dev1,0.1.0.dev5", - ) - v2 = PluginVersionInfo( - version="1.0.0", - released="2024-02-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v2, versions=[v1, v2]) - - # Dev version should match v1 - result = registry.get_latest_compatible("0.1.0.dev3") - assert result == v1 - - # Stable version should match v2 - result = registry.get_latest_compatible("0.1.5") - assert result == v2 - - def test_get_latest_compatible_boundary_conditions(self): - """get_latest_compatible should correctly handle boundary conditions.""" - from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry - - v1 = PluginVersionInfo( - version="1.0.0", - released="2024-01-01", - manifest_file="manifest.json", - min_max_framework_version="0.1.0,0.2.0", - ) - - registry = PluginVersionRegistry(latest=v1, versions=[v1]) - - # Exact min boundary - result = registry.get_latest_compatible("0.1.0") - assert result == v1 - - # Exact max boundary - result = registry.get_latest_compatible("0.2.0") - assert result == v1 - - # Just below min - result = registry.get_latest_compatible("0.0.9") - assert result is None - - # Just above max - result = registry.get_latest_compatible("0.2.1") - assert result is None - - def test_get_latest_compatible_with_empty_versions_list(self): - """get_latest_compatible should return None when versions list is empty.""" - from cpex.framework.models import PluginVersionRegistry - - registry = PluginVersionRegistry(latest=None, versions=[]) - - result = registry.get_latest_compatible("0.1.0") - assert result is None diff --git a/tests/unit/cpex/framework/test_plugin_models_coverage.py b/tests/unit/cpex/framework/test_plugin_models_coverage.py deleted file mode 100644 index fd5ab3b1..00000000 --- a/tests/unit/cpex/framework/test_plugin_models_coverage.py +++ /dev/null @@ -1,546 +0,0 @@ -# -*- coding: utf-8 -*- -"""Coverage tests for cpex.framework.models — world-writable UDS, gRPC configs, edge cases.""" - -# Standard -from pathlib import Path, PurePath -from unittest.mock import patch - -# Third-Party -import pytest -from pydantic import ValidationError - -# First-Party -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE -from cpex.framework.models import ( - GRPCClientConfig, - GRPCClientTLSConfig, - GRPCServerConfig, - GRPCServerTLSConfig, - MCPClientConfig, - MCPClientTLSConfig, - MCPServerConfig, - MCPServerTLSConfig, - PluginConfig, - TransportType, - UnixSocketClientConfig, - UnixSocketServerConfig, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _write_file(tmp_path: Path, name: str) -> str: - file_path = tmp_path / name - file_path.write_text("data") - return str(file_path) - - -# =========================================================================== -# World-writable UDS directory warnings -# =========================================================================== - - -class TestWorldWritableUDS: - def test_mcp_server_config_uds_world_writable(self, tmp_path, caplog): - uds_dir = tmp_path / "ww_dir" - uds_dir.mkdir() - uds_dir.chmod(0o777) - uds_path = str(uds_dir / "server.sock") - - config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) - assert config.uds is not None - assert any("world-writable" in r.message for r in caplog.records) - - def test_mcp_client_config_uds_world_writable(self, tmp_path, caplog): - uds_dir = tmp_path / "ww_dir" - uds_dir.mkdir() - uds_dir.chmod(0o777) - uds_path = str(uds_dir / "client.sock") - - config = MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url="http://localhost/mcp", uds=uds_path) - assert config.uds is not None - assert any("world-writable" in r.message for r in caplog.records) - - def test_grpc_client_config_uds_world_writable(self, tmp_path, caplog): - uds_dir = tmp_path / "ww_dir" - uds_dir.mkdir() - uds_dir.chmod(0o777) - uds_path = str(uds_dir / "grpc.sock") - - config = GRPCClientConfig(uds=uds_path) - assert config.uds is not None - assert any("world-writable" in r.message for r in caplog.records) - - def test_grpc_server_config_uds_world_writable(self, tmp_path, caplog): - uds_dir = tmp_path / "ww_dir" - uds_dir.mkdir() - uds_dir.chmod(0o777) - uds_path = str(uds_dir / "grpc_srv.sock") - - config = GRPCServerConfig(uds=uds_path) - assert config.uds is not None - assert any("world-writable" in r.message for r in caplog.records) - - -# =========================================================================== -# gRPC TLS from_env -# =========================================================================== - - -class TestGRPCTLSFromEnv: - def test_grpc_client_tls_from_env(self, monkeypatch, tmp_path): - cert = _write_file(tmp_path, "gc-cert.pem") - key = _write_file(tmp_path, "gc-key.pem") - ca = _write_file(tmp_path, "gc-ca.pem") - - monkeypatch.setenv("PLUGINS_GRPC_CLIENT_MTLS_CERTFILE", cert) - monkeypatch.setenv("PLUGINS_GRPC_CLIENT_MTLS_KEYFILE", key) - monkeypatch.setenv("PLUGINS_GRPC_CLIENT_MTLS_CA_BUNDLE", ca) - monkeypatch.setenv("PLUGINS_GRPC_CLIENT_MTLS_KEYFILE_PASSWORD", "secret") - monkeypatch.setenv("PLUGINS_GRPC_CLIENT_MTLS_VERIFY", "false") - - config = GRPCClientTLSConfig.from_env() - assert config is not None - assert config.verify is False - assert config.keyfile_password == "secret" - - def test_grpc_client_tls_from_env_empty(self, monkeypatch): - monkeypatch.delenv("PLUGINS_GRPC_CLIENT_MTLS_CERTFILE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_CLIENT_MTLS_KEYFILE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_CLIENT_MTLS_CA_BUNDLE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_CLIENT_MTLS_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_CLIENT_MTLS_VERIFY", raising=False) - config = GRPCClientTLSConfig.from_env() - assert config is None - - def test_grpc_server_tls_from_env(self, monkeypatch, tmp_path): - cert = _write_file(tmp_path, "gs-cert.pem") - key = _write_file(tmp_path, "gs-key.pem") - ca = _write_file(tmp_path, "gs-ca.pem") - - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE", key) - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_CERTFILE", cert) - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_CA_CERTS", ca) - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE_PASSWORD", "pw") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH", "optional") - - config = GRPCServerTLSConfig.from_env() - assert config is not None - assert config.client_auth == "optional" - - def test_grpc_server_tls_from_env_empty(self, monkeypatch): - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CERTFILE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CA_CERTS", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH", raising=False) - config = GRPCServerTLSConfig.from_env() - assert config is None - - -# =========================================================================== -# GRPCServerConfig from_env -# =========================================================================== - - -class TestGRPCServerConfigFromEnv: - def test_from_env_basic(self, monkeypatch, tmp_path): - monkeypatch.setenv("PLUGINS_GRPC_SERVER_HOST", "0.0.0.0") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_PORT", "50051") - monkeypatch.delenv("PLUGINS_GRPC_SERVER_UDS", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_ENABLED", raising=False) - - config = GRPCServerConfig.from_env() - assert config is not None - assert config.host == "0.0.0.0" - assert config.port == 50051 - - def test_from_env_invalid_port(self, monkeypatch): - monkeypatch.setenv("PLUGINS_GRPC_SERVER_PORT", "not_a_number") - with pytest.raises((ValueError, ValidationError), match="valid integer"): - GRPCServerConfig.from_env() - - def test_from_env_empty(self, monkeypatch): - monkeypatch.delenv("PLUGINS_GRPC_SERVER_HOST", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_PORT", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_UDS", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_ENABLED", raising=False) - config = GRPCServerConfig.from_env() - assert config is None - - def test_from_env_with_ssl(self, monkeypatch, tmp_path): - cert = _write_file(tmp_path, "cert.pem") - key = _write_file(tmp_path, "key.pem") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_HOST", "localhost") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_ENABLED", "true") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_CERTFILE", cert) - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE", key) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CA_CERTS", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH", raising=False) - - config = GRPCServerConfig.from_env() - assert config is not None - assert config.tls is not None - - -# =========================================================================== -# GRPCClientConfig methods -# =========================================================================== - - -class TestGRPCClientConfig: - def test_get_target_tcp(self): - config = GRPCClientConfig(target="localhost:50051") - assert config.get_target() == "localhost:50051" - - def test_get_target_uds(self, tmp_path): - uds_dir = tmp_path / "sock_dir" - uds_dir.mkdir() - uds_path = str(uds_dir / "grpc.sock") - config = GRPCClientConfig(uds=uds_path) - assert config.get_target().startswith("unix://") - - def test_validate_target_empty_raises(self): - with pytest.raises(ValueError, match="cannot be empty"): - GRPCClientConfig(target="") - - def test_validate_target_no_colon_raises(self): - with pytest.raises(ValueError, match="host:port"): - GRPCClientConfig(target="localhost") - - -# =========================================================================== -# GRPCServerConfig methods -# =========================================================================== - - -class TestGRPCServerConfigMethods: - def test_get_bind_address_tcp(self): - config = GRPCServerConfig(host="0.0.0.0", port=50051) - assert config.get_bind_address() == "0.0.0.0:50051" - - def test_get_bind_address_uds(self, tmp_path): - uds_dir = tmp_path / "sock_dir" - uds_dir.mkdir() - uds_path = str(uds_dir / "grpc.sock") - config = GRPCServerConfig(uds=uds_path) - assert config.get_bind_address().startswith("unix://") - - def test_validate_client_auth_valid(self): - config = GRPCServerTLSConfig(client_auth="optional") - assert config.client_auth == "optional" - - def test_validate_client_auth_invalid(self): - with pytest.raises(ValueError, match="client_auth must be one of"): - GRPCServerTLSConfig(client_auth="invalid") - - -# =========================================================================== -# UnixSocketServerConfig -# =========================================================================== - - -class TestUnixSocketServerConfig: - def test_from_env_with_path(self, monkeypatch): - monkeypatch.setenv("PLUGINS_UNIX_SOCKET_PATH", "/tmp/custom.sock") - config = UnixSocketServerConfig.from_env() - assert config is not None - assert config.path == "/tmp/custom.sock" - - def test_from_env_without_env(self, monkeypatch): - monkeypatch.delenv("PLUGINS_UNIX_SOCKET_PATH", raising=False) - config = UnixSocketServerConfig.from_env() - assert config is None - - -# =========================================================================== -# MCPServerConfig from_env -# =========================================================================== - - -class TestMCPServerConfigFromEnv: - def test_from_env_empty(self, monkeypatch): - monkeypatch.delenv("PLUGINS_SERVER_HOST", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_PORT", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_UDS", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_ENABLED", raising=False) - config = MCPServerConfig.from_env() - assert config is None - - def test_from_env_invalid_port(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - with pytest.raises((ValueError, ValidationError), match="valid integer"): - MCPServerConfig.from_env() - - def test_from_env_with_uds(self, monkeypatch, tmp_path): - uds_dir = tmp_path / "sock_dir" - uds_dir.mkdir() - uds_path = str(uds_dir / "server.sock") - monkeypatch.setenv("PLUGINS_SERVER_UDS", uds_path) - monkeypatch.delenv("PLUGINS_SERVER_HOST", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_PORT", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_ENABLED", raising=False) - - config = MCPServerConfig.from_env() - assert config is not None - assert config.uds is not None - - def test_from_env_with_ssl(self, monkeypatch, tmp_path): - cert = _write_file(tmp_path, "cert.pem") - key = _write_file(tmp_path, "key.pem") - monkeypatch.setenv("PLUGINS_SERVER_HOST", "localhost") - monkeypatch.setenv("PLUGINS_SERVER_SSL_ENABLED", "true") - monkeypatch.setenv("PLUGINS_SERVER_SSL_CERTFILE", cert) - monkeypatch.setenv("PLUGINS_SERVER_SSL_KEYFILE", key) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CA_CERTS", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERT_REQS", raising=False) - - config = MCPServerConfig.from_env() - assert config is not None - assert config.tls is not None - - def test_from_env_ssl_enabled_without_tls_data(self, monkeypatch): - """Cover branch where SSL is enabled but no TLS env vars are set (tls stays None).""" - monkeypatch.setenv("PLUGINS_SERVER_HOST", "127.0.0.1") - monkeypatch.setenv("PLUGINS_SERVER_SSL_ENABLED", "true") - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERTFILE", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_KEYFILE", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CA_CERTS", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERT_REQS", raising=False) - - config = MCPServerConfig.from_env() - assert config is not None - assert config.tls is None - - -# =========================================================================== -# MCPServerTLSConfig from_env -# =========================================================================== - - -class TestMCPServerTLSConfigFromEnv: - def test_from_env_with_ca_bundle_and_password(self, monkeypatch, tmp_path): - ca = _write_file(tmp_path, "server-ca.pem") - monkeypatch.setenv("PLUGINS_SERVER_SSL_CA_CERTS", ca) - monkeypatch.setenv("PLUGINS_SERVER_SSL_KEYFILE_PASSWORD", "pw") - monkeypatch.delenv("PLUGINS_SERVER_SSL_KEYFILE", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERTFILE", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERT_REQS", raising=False) - - config = MCPServerTLSConfig.from_env() - assert config is not None - assert config.ca_bundle is not None - assert config.keyfile_password == "pw" - - def test_from_env_empty_returns_none(self, monkeypatch): - monkeypatch.delenv("PLUGINS_SERVER_SSL_KEYFILE", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERTFILE", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CA_CERTS", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_SERVER_SSL_CERT_REQS", raising=False) - assert MCPServerTLSConfig.from_env() is None - - def test_invalid_cert_reqs(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_SSL_CERT_REQS", "invalid") - with pytest.raises((ValueError, ValidationError), match="valid integer"): - MCPServerTLSConfig.from_env() - - -# =========================================================================== -# Validators / edge branches (UDS, env parsing, PluginConfig) -# =========================================================================== - - -class TestMCPServerConfigValidators: - def test_uds_none(self): - config = MCPServerConfig(uds=None) - assert config.uds is None - - def test_uds_is_absolute_check_branch(self, tmp_path): - uds_path = tmp_path / "socket.sock" - with patch.object(PurePath, "is_absolute", return_value=False): - with pytest.raises(ValueError, match="must be absolute"): - MCPServerConfig(uds=str(uds_path)) - - def test_uds_parent_stat_oserror_is_ignored(self, tmp_path): - uds_path = tmp_path / "socket.sock" - with patch.object(Path, "is_dir", return_value=True), patch.object(Path, "stat", side_effect=OSError("boom")): - config = MCPServerConfig(uds=str(uds_path)) - assert config.uds is not None - - def test_bool_parsing_via_settings(self, monkeypatch): - """Bool fields on PluginsSettings handle false values correctly.""" - from cpex.framework.settings import PluginsSettings - - monkeypatch.setenv("PLUGINS_SERVER_SSL_ENABLED", "false") - assert PluginsSettings().server_ssl_enabled is False - - -class TestMCPClientConfigMoreBranches: - def test_validate_script_missing_file_raises(self, tmp_path): - missing = tmp_path / "missing.py" - with pytest.raises(ValueError, match="does not exist"): - MCPClientConfig(proto=TransportType.STDIO, script=str(missing)) - - def test_validate_script_py_file_allows_non_executable(self, tmp_path): - script = tmp_path / "script.py" - script.write_text("print('hi')") - config = MCPClientConfig(proto=TransportType.STDIO, script=str(script)) - assert config.script == str(script) - - def test_validate_env_empty_key_raises(self): - with pytest.raises(ValueError, match="env keys must be non-empty"): - MCPClientConfig(proto=TransportType.STDIO, cmd=["python"], env={"": "x"}) - - def test_validate_env_non_string_value_raises(self): - with pytest.raises(ValueError, match="env values must be strings"): - MCPClientConfig.validate_env({"KEY": 1}) # type: ignore[arg-type] - - def test_validate_uds_is_absolute_check_branch(self): - with patch.object(PurePath, "is_absolute", return_value=False): - with pytest.raises(ValueError, match="must be absolute"): - MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url="http://localhost/mcp", uds="/tmp/socket.sock") - - def test_validate_uds_parent_stat_oserror_is_ignored(self, tmp_path): - uds_path = tmp_path / "client.sock" - # Pre-cache plugin settings so the global Path.stat mock does not - # interfere with pydantic-settings .env file discovery inside the - # URL validator's deferred settings import. - from cpex.framework.settings import get_settings, get_ssrf_settings # pylint: disable=import-outside-toplevel - - get_settings() - get_ssrf_settings() - with patch.object(Path, "is_dir", return_value=True), patch.object(Path, "stat", side_effect=OSError("boom")): - config = MCPClientConfig(proto=TransportType.STREAMABLEHTTP, url="http://localhost/mcp", uds=str(uds_path)) - assert config.uds is not None - - -class TestGRPCUDSMoreBranches: - def test_grpc_client_uds_none(self): - config = GRPCClientConfig(target="localhost:50051", uds=None) - assert config.uds is None - - def test_grpc_client_uds_empty_string_raises(self): - with pytest.raises(ValueError, match="must be a non-empty string"): - GRPCClientConfig(uds="") - - def test_grpc_client_uds_is_absolute_check_branch(self): - with patch.object(PurePath, "is_absolute", return_value=False): - with pytest.raises(ValueError, match="must be absolute"): - GRPCClientConfig(uds="/tmp/grpc.sock") - - def test_grpc_client_uds_parent_stat_oserror_is_ignored(self): - with patch.object(Path, "is_dir", return_value=True), patch.object(Path, "stat", side_effect=OSError("boom")): - config = GRPCClientConfig(uds="/tmp/grpc.sock") - assert config.uds is not None - - def test_grpc_server_uds_none(self): - config = GRPCServerConfig(uds=None) - assert config.uds is None - - def test_grpc_server_uds_empty_string_raises(self): - with pytest.raises(ValueError, match="must be a non-empty string"): - GRPCServerConfig(uds="") - - def test_grpc_server_uds_is_absolute_check_branch(self): - with patch.object(PurePath, "is_absolute", return_value=False): - with pytest.raises(ValueError, match="must be absolute"): - GRPCServerConfig(uds="/tmp/grpc.sock") - - def test_grpc_server_uds_parent_stat_oserror_is_ignored(self): - with patch.object(Path, "is_dir", return_value=True), patch.object(Path, "stat", side_effect=OSError("boom")): - config = GRPCServerConfig(uds="/tmp/grpc.sock") - assert config.uds is not None - - def test_grpc_server_from_env_ssl_enabled_without_tls_data(self, monkeypatch): - monkeypatch.setenv("PLUGINS_GRPC_SERVER_HOST", "localhost") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_ENABLED", "true") - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CERTFILE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CA_CERTS", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_KEYFILE_PASSWORD", raising=False) - monkeypatch.delenv("PLUGINS_GRPC_SERVER_SSL_CLIENT_AUTH", raising=False) - - config = GRPCServerConfig.from_env() - assert config is not None - assert config.tls is None - - -class TestUnixSocketClientConfigBranches: - def test_unix_socket_client_path_empty_raises(self): - with pytest.raises(ValueError, match="cannot be empty"): - UnixSocketClientConfig(path="") - - def test_unix_socket_client_path_not_absolute_raises(self): - with pytest.raises(ValueError, match="must be absolute"): - UnixSocketClientConfig(path="relative.sock") - - -class TestPluginConfigBranches: - def test_plugin_config_rejects_unknown_mcp_transport_type(self): - mcp = MCPClientConfig(proto=TransportType.HTTP, url="http://localhost/mcp") - with pytest.raises(ValueError, match="must set transport type"): - PluginConfig(name="plug", kind="internal", mcp=mcp) - - def test_external_plugin_cannot_have_multiple_transports(self): - mcp = MCPClientConfig(proto=TransportType.SSE, url="http://localhost/mcp") - grpc = GRPCClientConfig(target="localhost:50051") - with pytest.raises(ValueError, match="only have one transport configured"): - PluginConfig(name="external", kind=EXTERNAL_PLUGIN_TYPE, mcp=mcp, grpc=grpc) - - -# =========================================================================== -# Settings isolation: from_env() must not fail on unrelated malformed env vars -# =========================================================================== - - -class TestFromEnvSettingsIsolation: - """Verify from_env() methods use lightweight settings and ignore unrelated env vars.""" - - def test_mcp_server_from_env_ignores_malformed_grpc_port(self, monkeypatch): - monkeypatch.setenv("PLUGINS_GRPC_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_SERVER_HOST", "localhost") - config = MCPServerConfig.from_env() - assert config is not None - assert config.host == "localhost" - - def test_grpc_server_from_env_ignores_malformed_mcp_port(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_HOST", "0.0.0.0") - config = GRPCServerConfig.from_env() - assert config is not None - assert config.host == "0.0.0.0" - - def test_unix_socket_from_env_ignores_malformed_server_port(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_UNIX_SOCKET_PATH", "/tmp/test.sock") - config = UnixSocketServerConfig.from_env() - assert config is not None - assert config.path == "/tmp/test.sock" - - def test_transport_property_ignores_malformed_server_port(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") - settings.cache_clear() - try: - assert settings.transport == "stdio" - finally: - settings.cache_clear() - - def test_mcp_client_tls_from_env_ignores_malformed_server_port(self, monkeypatch, tmp_path): - cert = tmp_path / "client-cert.pem" - cert.write_text("data", encoding="utf-8") - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CERTFILE", str(cert)) - - config = MCPClientTLSConfig.from_env() - assert config is not None - assert config.certfile == str(cert) diff --git a/tests/unit/cpex/framework/test_plugin_modes.py b/tests/unit/cpex/framework/test_plugin_modes.py deleted file mode 100644 index 6de75413..00000000 --- a/tests/unit/cpex/framework/test_plugin_modes.py +++ /dev/null @@ -1,889 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_plugin_modes.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Tests for PluginMode / OnError refactor: - - FIRE_AND_FORGET fire-and-forget semantics - - OnError.IGNORE / DISABLE / FAIL behaviors - - CONCURRENT parallel execution (can block, cannot modify) - - SEQUENTIAL chained execution (can block + modify) - - TRANSFORM chained execution (can modify, cannot block) - - AUDIT sequential execution (observe-only: cannot halt or modify) - - Phase ordering: SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET - - execution_pool semaphore for FIRE_AND_FORGET tasks - - Backward-compat migration: enforce / enforce_ignore_error → SEQUENTIAL, permissive → TRANSFORM -""" - -# Standard -import asyncio -from unittest.mock import patch - -# Third-Party -import pytest - -from cpex.framework import ( - GlobalContext, - OnError, - Plugin, - PluginConfig, - PluginError, - PluginManager, - PluginMode, - PluginResult, - PromptHookType, - PromptPrehookPayload, -) - -# First-Party -from cpex.framework.base import HookRef -from cpex.framework.registry import PluginRef - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def make_plugin_config( - name: str, mode: PluginMode, on_error: OnError = OnError.FAIL, priority: int = 100 -) -> PluginConfig: - return PluginConfig( - name=name, - description="test", - author="test", - version="1.0", - kind="test.Plugin", - mode=mode, - on_error=on_error, - hooks=["prompt_pre_fetch"], - tags=[], - priority=priority, - ) - - -async def _make_manager() -> PluginManager: - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - return manager - - -# --------------------------------------------------------------------------- -# FIRE_AND_FORGET mode -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fire_and_forget_mode_fires_background_task(): - """Pipeline continues immediately without waiting for a FIRE_AND_FORGET plugin.""" - - started = asyncio.Event() - finished = asyncio.Event() - - class SlowFireAndForgetPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - started.set() - await asyncio.sleep(0.05) - finished.set() - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg = make_plugin_config("SlowFireAndForget", PluginMode.FIRE_AND_FORGET) - plugin = SlowFireAndForgetPlugin(cfg) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="1") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - # Pipeline result is returned immediately; FIRE_AND_FORGET task hasn't finished yet - assert result.continue_processing - assert not finished.is_set() - - # Wait deterministically for the background task to complete - await result.wait_for_background_tasks() - assert finished.is_set() - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_fire_and_forget_mode_error_does_not_block(): - """A FIRE_AND_FORGET plugin that errors must not halt or affect the pipeline.""" - - class BrokenFireAndForgetPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - raise RuntimeError("fire_and_forget error") - - manager = await _make_manager() - cfg = make_plugin_config("BrokenFireAndForget", PluginMode.FIRE_AND_FORGET) - plugin = BrokenFireAndForgetPlugin(cfg) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="2") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - - # Wait for the background task; errors are returned, not raised - await result.wait_for_background_tasks() - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_wait_for_background_tasks_returns_errors(): - """wait_for_background_tasks() returns a PluginErrorModel for each failed task.""" - - class BrokenPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - raise RuntimeError("boom") - - manager = await _make_manager() - cfg = make_plugin_config("BrokenFnF", PluginMode.FIRE_AND_FORGET) - plugin = BrokenPlugin(cfg) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="wait_errors") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - errors = await result.wait_for_background_tasks() - assert len(errors) == 1 - assert errors[0].plugin_name == "BrokenFnF" - assert "RuntimeError" in errors[0].message - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# OnError behaviors (CONCURRENT mode) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_on_error_ignore_continues_pipeline(): - """CONCURRENT + on_error=IGNORE: error is logged, pipeline continues.""" - - class FailPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - raise RuntimeError("intentional error") - - manager = await _make_manager() - cfg = make_plugin_config("IgnorePlugin", PluginMode.CONCURRENT, on_error=OnError.IGNORE) - plugin = FailPlugin(cfg) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="3") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - assert result.violation is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_on_error_disable_disables_plugin(): - """CONCURRENT + on_error=DISABLE: plugin is runtime-disabled after first error.""" - - call_count = 0 - - class DisablePlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - nonlocal call_count - call_count += 1 - raise RuntimeError("first call fails") - - manager = await _make_manager() - cfg = make_plugin_config("DisablePlugin", PluginMode.CONCURRENT, on_error=OnError.DISABLE) - plugin = DisablePlugin(cfg) - hook_ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [hook_ref] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="4") - - # First call: error is caught, plugin gets disabled - result1, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - assert result1.continue_processing - - # Second call: plugin is in _runtime_disabled, should be skipped - result2, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - assert result2.continue_processing - - # Plugin ran exactly once (skipped on second call) - assert call_count == 1 - assert "DisablePlugin" in manager._executor._runtime_disabled - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_on_error_fail_raises(): - """CONCURRENT + on_error=FAIL (default): error propagates as PluginError.""" - - class FailPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - raise RuntimeError("fail!") - - manager = await _make_manager() - cfg = make_plugin_config("FailPlugin", PluginMode.CONCURRENT, on_error=OnError.FAIL) - plugin = FailPlugin(cfg) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="5") - - with pytest.raises(PluginError): - await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# CONCURRENT parallel execution -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_concurrent_parallel_execution(): - """Multiple CONCURRENT plugins should run concurrently.""" - - results_order: list[str] = [] - start_barrier = asyncio.Event() - - class SlowPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - results_order.append(f"start:{self.name}") - await start_barrier.wait() - await asyncio.sleep(0.01) - results_order.append(f"end:{self.name}") - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg1 = make_plugin_config("ConcP1", PluginMode.CONCURRENT, priority=1) - cfg2 = make_plugin_config("ConcP2", PluginMode.CONCURRENT, priority=2) - plugin1 = SlowPlugin(cfg1) - plugin2 = SlowPlugin(cfg2) - - ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref1, ref2] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="6") - - # Let the invoke run while releasing the barrier concurrently - async def release_and_invoke(): - task = asyncio.create_task(manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context)) - await asyncio.sleep(0.005) # Let both plugins reach the barrier - start_barrier.set() - return await task - - result, _ = await release_and_invoke() - - assert result.continue_processing - # Both plugins started before either finished (parallel execution) - assert results_order[:2] == ["start:ConcP1", "start:ConcP2"] - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# AUDIT sequential execution -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_audit_sequential_execution(): - """AUDIT plugins execute in priority order sequentially.""" - - call_order: list[str] = [] - - class SequentialPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append(self.name) - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg1 = make_plugin_config("PermP1", PluginMode.AUDIT, priority=1) - cfg2 = make_plugin_config("PermP2", PluginMode.AUDIT, priority=2) - plugin1 = SequentialPlugin(cfg1) - plugin2 = SequentialPlugin(cfg2) - - ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref1, ref2] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="7") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - assert call_order == ["PermP1", "PermP2"] - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# execution_pool semaphore -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_execution_pool_semaphore(): - """With execution_pool=1, FIRE_AND_FORGET tasks are serialized.""" - - concurrency_high_water = 0 - current_concurrent = 0 - - class ConcurrencyProbePlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - nonlocal concurrency_high_water, current_concurrent - current_concurrent += 1 - concurrency_high_water = max(concurrency_high_water, current_concurrent) - await asyncio.sleep(0.02) - current_concurrent -= 1 - return PluginResult(continue_processing=True) - - manager = await _make_manager() - - cfg1 = make_plugin_config("ObsPool1", PluginMode.FIRE_AND_FORGET, priority=1) - cfg2 = make_plugin_config("ObsPool2", PluginMode.FIRE_AND_FORGET, priority=2) - plugin1 = ConcurrencyProbePlugin(cfg1) - plugin2 = ConcurrencyProbePlugin(cfg2) - - ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref1, ref2] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="8") - - # Patch execution_pool=1 via the settings layer - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = 1 - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - - # Wait deterministically for all background FIRE_AND_FORGET tasks to complete - await result.wait_for_background_tasks() - - # With pool=1, max concurrency should be 1 - assert concurrency_high_water <= 1 - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# Backward compatibility: YAML migration -# --------------------------------------------------------------------------- - - -def test_plugin_config_migration_enforce_ignore_error(): - """Legacy 'enforce_ignore_error' mode migrates to SEQUENTIAL + on_error=ignore.""" - cfg = PluginConfig.model_validate( - { - "name": "legacy", - "kind": "test.Plugin", - "mode": "enforce_ignore_error", - } - ) - assert cfg.mode == PluginMode.SEQUENTIAL - assert cfg.on_error == OnError.IGNORE - - -def test_plugin_config_migration_preserves_explicit_on_error(): - """Migration does not override an explicitly provided on_error value.""" - cfg = PluginConfig.model_validate( - { - "name": "legacy2", - "kind": "test.Plugin", - "mode": "enforce_ignore_error", - "on_error": "disable", - } - ) - assert cfg.mode == PluginMode.SEQUENTIAL - assert cfg.on_error == OnError.DISABLE - - -def test_plugin_config_migration_enforce_to_sequential(): - """Legacy 'enforce' mode migrates to SEQUENTIAL.""" - cfg = PluginConfig.model_validate( - { - "name": "legacy_enforce", - "kind": "test.Plugin", - "mode": "enforce", - } - ) - assert cfg.mode == PluginMode.SEQUENTIAL - - -def test_plugin_config_migration_permissive_to_transform(): - """Legacy 'permissive' mode migrates to TRANSFORM.""" - cfg = PluginConfig.model_validate( - { - "name": "legacy_permissive", - "kind": "test.Plugin", - "mode": "permissive", - } - ) - assert cfg.mode == PluginMode.TRANSFORM - - -# --------------------------------------------------------------------------- -# SEQUENTIAL mode tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_sequential_chains_payload(): - """Each SEQUENTIAL plugin receives the output payload of the previous plugin.""" - - received_payloads: list = [] - - class ChainPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - step = int(payload.args.get("step", "0")) - received_payloads.append(step) - new_payload = PromptPrehookPayload(prompt_id=payload.prompt_id, args={"step": str(step + 1)}) - return PluginResult(continue_processing=True, modified_payload=new_payload) - - manager = await _make_manager() - cfg1 = make_plugin_config("SeqChain1", PluginMode.SEQUENTIAL, priority=1) - cfg2 = make_plugin_config("SeqChain2", PluginMode.SEQUENTIAL, priority=2) - plugin1 = ChainPlugin(cfg1) - plugin2 = ChainPlugin(cfg2) - - ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref1, ref2] - payload = PromptPrehookPayload(prompt_id="test", args={"step": "0"}) - global_context = GlobalContext(request_id="seq1") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - # First plugin saw step=0, second saw step=1 (chained) - assert received_payloads[0] == 0 - assert received_payloads[1] == 1 - # Final payload has step=2 - assert result.modified_payload is not None - assert result.modified_payload.args["step"] == "2" - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_sequential_can_halt_pipeline(): - """A SEQUENTIAL plugin that returns continue_processing=False halts the pipeline.""" - - call_order: list[str] = [] - - class HaltPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append(self.name) - return PluginResult(continue_processing=False) - - class NeverReachedPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append(self.name) - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg_halt = make_plugin_config("SeqHalt", PluginMode.SEQUENTIAL, priority=1) - cfg_after = make_plugin_config("SeqAfter", PluginMode.SEQUENTIAL, priority=2) - plugin_halt = HaltPlugin(cfg_halt) - plugin_after = NeverReachedPlugin(cfg_after) - - ref_halt = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_halt)) - ref_after = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_after)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref_halt, ref_after] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="seq2") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert not result.continue_processing - assert call_order == ["SeqHalt"] # second plugin never ran - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_sequential_executes_before_transform(): - """SEQUENTIAL plugins run before TRANSFORM plugins regardless of priority.""" - - call_order: list[str] = [] - - class OrderPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append(self.name) - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg_seq = make_plugin_config("SeqFirst", PluginMode.SEQUENTIAL, priority=10) - cfg_xform = make_plugin_config("XformSecond", PluginMode.TRANSFORM, priority=1) # lower priority number - plugin_seq = OrderPlugin(cfg_seq) - plugin_xform = OrderPlugin(cfg_xform) - - ref_seq = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_seq)) - ref_xform = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_xform)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref_seq, ref_xform] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="seq3") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - assert call_order == ["SeqFirst", "XformSecond"] - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_sequential_executes_before_concurrent(): - """SEQUENTIAL plugins run before CONCURRENT plugins.""" - - call_order: list[str] = [] - - class OrderPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append(self.name) - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg_seq = make_plugin_config("SeqFirst2", PluginMode.SEQUENTIAL, priority=10) - cfg_conc = make_plugin_config("ConcSecond", PluginMode.CONCURRENT, priority=1) - plugin_seq = OrderPlugin(cfg_seq) - plugin_conc = OrderPlugin(cfg_conc) - - ref_seq = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_seq)) - ref_conc = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_conc)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref_seq, ref_conc] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="seq4") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - assert call_order == ["SeqFirst2", "ConcSecond"] - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# Phase ordering tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_phase_order_all_five_modes(): - """All five phases execute in the correct order: SEQ → TRANSFORM → AUDIT → CONC → F&F.""" - - phase_log: list[str] = [] - fnf_event = asyncio.Event() - - class LogPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - phase_log.append(self.name) - if self.name == "fnf": - fnf_event.set() - return PluginResult(continue_processing=True) - - manager = await _make_manager() - modes = [ - ("seq", PluginMode.SEQUENTIAL), - ("xform", PluginMode.TRANSFORM), - ("audit", PluginMode.AUDIT), - ("conc", PluginMode.CONCURRENT), - ("fnf", PluginMode.FIRE_AND_FORGET), - ] - refs = [] - for name, mode in modes: - cfg = make_plugin_config(name, mode, priority=1) - plugin = LogPlugin(cfg) - refs.append(HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = refs - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="phase_all") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert result.continue_processing - # F&F is async — wait for it deterministically - await result.wait_for_background_tasks() - - assert phase_log == ["seq", "xform", "audit", "conc", "fnf"] - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_fire_and_forget_fires_after_all_phases(): - """FIRE_AND_FORGET tasks are scheduled after SEQUENTIAL and CONCURRENT phases complete.""" - - phase_log: list[str] = [] - fire_and_forget_started = asyncio.Event() - - class SeqPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - phase_log.append("sequential") - return PluginResult(continue_processing=True) - - class FnfPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - fire_and_forget_started.set() - phase_log.append("fire_and_forget") - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg_seq = make_plugin_config("FnfSeq", PluginMode.SEQUENTIAL, priority=1) - cfg_fnf = make_plugin_config("FnfFnf", PluginMode.FIRE_AND_FORGET, priority=1) - plugin_seq = SeqPlugin(cfg_seq) - plugin_fnf = FnfPlugin(cfg_fnf) - - ref_seq = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_seq)) - ref_fnf = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin_fnf)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref_seq, ref_fnf] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="obs_order") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - # Pipeline has returned; sequential ran synchronously - assert "sequential" in phase_log - # FIRE_AND_FORGET has not yet completed (fire-and-forget) - assert not fire_and_forget_started.is_set() - - await result.wait_for_background_tasks() - assert "fire_and_forget" in phase_log - # FIRE_AND_FORGET always comes after sequential in the log - assert phase_log.index("sequential") < phase_log.index("fire_and_forget") - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# TRANSFORM mode tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_transform_chains_payload(): - """Each TRANSFORM plugin receives the chained output of the previous plugin.""" - - received_payloads: list = [] - - class ChainPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - step = int(payload.args.get("step", "0")) - received_payloads.append(step) - new_payload = PromptPrehookPayload(prompt_id=payload.prompt_id, args={"step": str(step + 1)}) - return PluginResult(continue_processing=True, modified_payload=new_payload) - - manager = await _make_manager() - cfg1 = make_plugin_config("XformChain1", PluginMode.TRANSFORM, priority=1) - cfg2 = make_plugin_config("XformChain2", PluginMode.TRANSFORM, priority=2) - plugin1 = ChainPlugin(cfg1) - plugin2 = ChainPlugin(cfg2) - - ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref1, ref2] - payload = PromptPrehookPayload(prompt_id="test", args={"step": "0"}) - global_context = GlobalContext(request_id="xform1") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert received_payloads == [0, 1] - assert result.modified_payload is not None - assert result.modified_payload.args["step"] == "2" - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_transform_cannot_halt_pipeline(): - """A TRANSFORM plugin returning continue_processing=False is suppressed.""" - - call_order: list[str] = [] - - class BlockingTransform(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append("blocker") - return PluginResult(continue_processing=False) - - class AfterTransform(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append("after") - return PluginResult(continue_processing=True) - - manager = await _make_manager() - cfg1 = make_plugin_config("XformBlock", PluginMode.TRANSFORM, priority=1) - cfg2 = make_plugin_config("XformAfter", PluginMode.TRANSFORM, priority=2) - plugin1 = BlockingTransform(cfg1) - plugin2 = AfterTransform(cfg2) - - ref1 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin1)) - ref2 = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin2)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref1, ref2] - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="xform2") - - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - # Pipeline was NOT halted — both plugins ran - assert result.continue_processing - assert call_order == ["blocker", "after"] - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_transform_executes_after_sequential_before_audit(): - """TRANSFORM phase runs between SEQUENTIAL and AUDIT.""" - - call_order: list[str] = [] - - class OrderPlugin(Plugin): - async def prompt_pre_fetch(self, payload, context): - call_order.append(self.name) - return PluginResult(continue_processing=True) - - manager = await _make_manager() - configs = [ - ("AuditP", PluginMode.AUDIT, 1), - ("XformP", PluginMode.TRANSFORM, 1), - ("SeqP", PluginMode.SEQUENTIAL, 1), - ] - refs = [] - for name, mode, prio in configs: - cfg = make_plugin_config(name, mode, priority=prio) - plugin = OrderPlugin(cfg) - refs.append(HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin))) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = refs - payload = PromptPrehookPayload(prompt_id="test", args={}) - global_context = GlobalContext(request_id="xform3") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - assert call_order == ["SeqP", "XformP", "AuditP"] - - await manager.shutdown() - - -# --------------------------------------------------------------------------- -# Modification discard regression tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_audit_modification_discarded(): - """AUDIT plugins that return modified_payload have their changes silently discarded.""" - - class AuditModifier(Plugin): - async def prompt_pre_fetch(self, payload, context): - new_payload = PromptPrehookPayload(prompt_id=payload.prompt_id, args={"injected": "yes"}) - return PluginResult(continue_processing=True, modified_payload=new_payload) - - manager = await _make_manager() - cfg = make_plugin_config("AuditMod", PluginMode.AUDIT, priority=1) - plugin = AuditModifier(cfg) - - ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref] - payload = PromptPrehookPayload(prompt_id="test", args={"original": "yes"}) - global_context = GlobalContext(request_id="audit_mod") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - # Payload should be unchanged — AUDIT cannot modify - assert result.modified_payload is None - - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_concurrent_modification_discarded(): - """CONCURRENT plugins that return modified_payload have their changes silently discarded.""" - - class ConcModifier(Plugin): - async def prompt_pre_fetch(self, payload, context): - new_payload = PromptPrehookPayload(prompt_id=payload.prompt_id, args={"injected": "yes"}) - return PluginResult(continue_processing=True, modified_payload=new_payload) - - manager = await _make_manager() - cfg = make_plugin_config("ConcMod", PluginMode.CONCURRENT, priority=1) - plugin = ConcModifier(cfg) - - ref = HookRef(PromptHookType.PROMPT_PRE_FETCH, PluginRef(plugin)) - - with patch.object(manager._registry, "get_hook_refs_for_hook") as mock_get: - mock_get.return_value = [ref] - payload = PromptPrehookPayload(prompt_id="test", args={"original": "yes"}) - global_context = GlobalContext(request_id="conc_mod") - - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.execution_pool = None - mock_settings.default_hook_policy = "allow" - result, _ = await manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context) - - # Payload should be unchanged — CONCURRENT cannot modify - assert result.modified_payload is None - - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_policies.py b/tests/unit/cpex/framework/test_policies.py deleted file mode 100644 index 7726c49d..00000000 --- a/tests/unit/cpex/framework/test_policies.py +++ /dev/null @@ -1,1170 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_policies.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests for hook payload policies. -""" - -# Standard -from unittest.mock import patch - -# Third-Party -import pytest -from pydantic import BaseModel, Field, ValidationError - -# First-Party -from cpex.framework.hooks.policies import DefaultHookPolicy, HookPayloadPolicy, apply_policy -from cpex.framework.models import PluginPayload -from tests.unit.cpex.fixtures.common.models import Message, PromptResult, Role, TextContent - - -class SamplePayload(PluginPayload): - """Test payload with writable and non-writable fields.""" - - name: str - args: dict = Field(default_factory=dict) - secret: str = "original" - - -class TestHookPayloadPolicy: - """Tests for HookPayloadPolicy dataclass.""" - - def test_policy_is_frozen(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name"})) - with pytest.raises(AttributeError): - policy.writable_fields = frozenset({"other"}) # type: ignore[misc] - - def test_writable_fields_membership(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name", "args"})) - assert "name" in policy.writable_fields - assert "args" in policy.writable_fields - assert "secret" not in policy.writable_fields - - -class TestDefaultHookPolicy: - """Tests for DefaultHookPolicy enum.""" - - def test_allow_value(self): - assert DefaultHookPolicy.ALLOW.value == "allow" - - def test_deny_value(self): - assert DefaultHookPolicy.DENY.value == "deny" - - def test_from_string(self): - assert DefaultHookPolicy("allow") == DefaultHookPolicy.ALLOW - assert DefaultHookPolicy("deny") == DefaultHookPolicy.DENY - - -class TestApplyPolicy: - """Tests for apply_policy function.""" - - def test_allows_writable_field_change(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name", "args"})) - original = SamplePayload(name="old", args={"key": "val"}, secret="s") - modified = SamplePayload(name="new", args={"key": "val"}, secret="s") - - result = apply_policy(original, modified, policy) - assert result is not None - assert result.name == "new" # type: ignore[union-attr] - - def test_filters_non_writable_field(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name"})) - original = SamplePayload(name="old", secret="original") - modified = SamplePayload(name="new", secret="hacked") - - result = apply_policy(original, modified, policy) - assert result is not None - assert result.name == "new" # type: ignore[union-attr] - assert result.secret == "original" # type: ignore[union-attr] - - def test_returns_none_when_no_effective_changes(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name"})) - original = SamplePayload(name="same", secret="s") - modified = SamplePayload(name="same", secret="hacked") - - result = apply_policy(original, modified, policy) - assert result is None - - def test_returns_none_for_identical_payloads(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name", "args", "secret"})) - original = SamplePayload(name="same", args={}, secret="s") - modified = SamplePayload(name="same", args={}, secret="s") - - result = apply_policy(original, modified, policy) - assert result is None - - def test_multiple_writable_fields_changed(self): - policy = HookPayloadPolicy(writable_fields=frozenset({"name", "args"})) - original = SamplePayload(name="old", args={"a": "1"}, secret="s") - modified = SamplePayload(name="new", args={"b": "2"}, secret="hacked") - - result = apply_policy(original, modified, policy) - assert result is not None - assert result.name == "new" # type: ignore[union-attr] - assert result.args == {"b": "2"} # type: ignore[union-attr] - assert result.secret == "s" # type: ignore[union-attr] - - def test_empty_writable_fields_rejects_all(self): - policy = HookPayloadPolicy(writable_fields=frozenset()) - original = SamplePayload(name="old", secret="s") - modified = SamplePayload(name="new", secret="hacked") - - result = apply_policy(original, modified, policy) - assert result is None - - def test_sentinel_skip_for_missing_attribute(self): - """When a declared field is absent from the modified instance's __dict__ - (defensive guard), the field is skipped via the _SENTINEL check.""" - policy = HookPayloadPolicy(writable_fields=frozenset({"name", "secret"})) - original = SamplePayload(name="old", secret="s") - modified = SamplePayload(name="new", secret="changed") - - # Remove 'secret' from the modified instance's internal storage - # to trigger the new_val is _SENTINEL branch - del modified.__dict__["secret"] - - result = apply_policy(original, modified, policy) - assert result is not None - assert result.name == "new" # type: ignore[union-attr] - # secret should be unchanged (skipped because sentinel) - assert result.secret == "s" # type: ignore[union-attr] - - def test_basemodel_field_equal_skipped(self): - """When both old and new values are BaseModel instances with identical - content, apply_policy uses model_dump() comparison and skips the field.""" - - class Inner(BaseModel): - x: int = 1 - y: str = "hello" - - class PayloadWithModel(PluginPayload): - name: str - nested: Inner = Field(default_factory=Inner) - - policy = HookPayloadPolicy(writable_fields=frozenset({"name", "nested"})) - original = PayloadWithModel(name="old", nested=Inner(x=1, y="hello")) - modified = PayloadWithModel(name="new", nested=Inner(x=1, y="hello")) - - result = apply_policy(original, modified, policy) - assert result is not None - assert result.name == "new" # type: ignore[union-attr] - # nested is structurally identical so should not appear in updates - assert result.nested.x == 1 # type: ignore[union-attr] - - def test_basemodel_field_changed_accepted(self): - """When both old and new values are BaseModel but differ, the writable - field change is accepted via model_dump() comparison.""" - - class Inner(BaseModel): - x: int = 1 - - class PayloadWithModel(PluginPayload): - name: str - nested: Inner = Field(default_factory=Inner) - - policy = HookPayloadPolicy(writable_fields=frozenset({"nested"})) - original = PayloadWithModel(name="old", nested=Inner(x=1)) - modified = PayloadWithModel(name="old", nested=Inner(x=99)) - - result = apply_policy(original, modified, policy) - assert result is not None - assert result.nested.x == 99 # type: ignore[union-attr] - - def test_copyonwritedict_args_empty_modification_preserved(self): - """Regression test for bug where CopyOnWriteDict equality caused - apply_policy to drop valid empty args modification. - - When a plugin receives args as CopyOnWriteDict with data and returns - an empty dict, apply_policy should treat this as a valid modification. - Previously, CopyOnWriteDict.__eq__ was not implemented, causing the - comparison to use dict's default equality which compared the empty - base storage, incorrectly returning True for CopyOnWriteDict({...}) == {}. - """ - from cpex.framework.memory import CopyOnWriteDict - - policy = HookPayloadPolicy(writable_fields=frozenset({"args"})) - - # Simulate plugin receiving payload with CopyOnWriteDict args - original = SamplePayload( - name="test", - args=CopyOnWriteDict({ - "wxo_connection_id": "", - "wxo_auth": "fake-token", - "wxo_environment_id": "draft", - }), - secret="s", - ) - - # Plugin strips all args, returning empty dict - modified = SamplePayload(name="test", args={}, secret="s") - - result = apply_policy(original, modified, policy) - - # The modification should be preserved, not dropped - assert result is not None, "apply_policy should not return None when args changed from {...} to {}" - assert result.args == {} # type: ignore[union-attr] - assert result.name == "test" # type: ignore[union-attr] - assert result.secret == "s" # type: ignore[union-attr] - - def test_copyonwritedict_args_partial_modification_preserved(self): - """Test that partial arg removal is also preserved correctly.""" - from cpex.framework.memory import CopyOnWriteDict - - policy = HookPayloadPolicy(writable_fields=frozenset({"args"})) - - original = SamplePayload( - name="test", - args=CopyOnWriteDict({ - "wxo_auth": "token", - "real_arg": "value", - }), - secret="s", - ) - - # Plugin removes only wxo_auth, keeping real_arg - modified = SamplePayload(name="test", args={"real_arg": "value"}, secret="s") - - result = apply_policy(original, modified, policy) - - assert result is not None - assert result.args == {"real_arg": "value"} # type: ignore[union-attr] - - -class TestPluginPayloadFrozen: - """Tests for frozen PluginPayload base class.""" - - def test_payload_is_immutable(self): - payload = SamplePayload(name="test", args={}, secret="s") - with pytest.raises(ValidationError, match="frozen"): - payload.name = "changed" # type: ignore[misc] - - def test_payload_model_copy(self): - payload = SamplePayload(name="test", args={}, secret="s") - copied = payload.model_copy(update={"name": "updated"}) - assert copied.name == "updated" - assert payload.name == "test" # original unchanged - - -class TestAgentMessageCoercion: - """Tests for _coerce_messages field validator on agent payloads.""" - - def test_pre_invoke_dict_messages_coerced(self): - from cpex.framework.hooks.agents import AgentPreInvokePayload - from cpex.framework.utils import StructuredData - - payload = AgentPreInvokePayload( - agent_id="agent-1", - messages=[{"role": "user", "content": {"type": "text", "text": "hello"}}], - ) - assert isinstance(payload.messages[0], StructuredData) - assert payload.messages[0].role == "user" - assert payload.messages[0].content.text == "hello" - - def test_post_invoke_dict_messages_coerced(self): - from cpex.framework.hooks.agents import AgentPostInvokePayload - from cpex.framework.utils import StructuredData - - payload = AgentPostInvokePayload( - agent_id="agent-1", - messages=[{"role": "assistant", "content": {"type": "text", "text": "world"}}], - ) - assert isinstance(payload.messages[0], StructuredData) - assert payload.messages[0].content.text == "world" - - def test_real_message_objects_pass_through(self): - from cpex.framework.hooks.agents import AgentPreInvokePayload - - msg = Message(role="user", content=TextContent(type="text", text="hi")) - payload = AgentPreInvokePayload(agent_id="agent-1", messages=[msg]) - assert payload.messages[0] is msg - - def test_empty_messages_list(self): - from cpex.framework.hooks.agents import AgentPreInvokePayload - - payload = AgentPreInvokePayload(agent_id="agent-1", messages=[]) - assert payload.messages == [] - - -class TestProtocolConformance: - """Verify gateway concrete types satisfy framework protocols.""" - - def test_message_satisfies_message_like(self): - from cpex.framework.protocols import MessageLike - - msg = Message(role="user", content=TextContent(type="text", text="hello")) - assert isinstance(msg, MessageLike) - - def test_prompt_result_satisfies_prompt_result_like(self): - from cpex.framework.protocols import PromptResultLike - - result = PromptResult( - messages=[Message(role="user", content=TextContent(type="text", text="hi"))], - description="test", - ) - assert isinstance(result, PromptResultLike) - - def test_simple_namespace_satisfies_message_like(self): - from types import SimpleNamespace - - from cpex.framework.protocols import MessageLike - - msg = SimpleNamespace(role=Role.USER, content="hello") - assert isinstance(msg, MessageLike) - - -class TestPromptPosthookCoercion: - """Tests for PromptPosthookPayload._coerce_result field validator.""" - - def test_dict_result_coerced_to_structured_data(self): - from cpex.framework.hooks.prompts import PromptPosthookPayload - from cpex.framework.utils import StructuredData - - payload = PromptPosthookPayload( - prompt_id="test", - result={"messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}]}, - ) - assert isinstance(payload.result, StructuredData) - assert payload.result.messages[0].content.text == "hi" - - def test_non_dict_result_passthrough(self): - from cpex.framework.hooks.prompts import PromptPosthookPayload - - ns = PromptResult(messages=[], description=None) - payload = PromptPosthookPayload(prompt_id="test", result=ns) - assert payload.result is ns - - def test_pydantic_model_result_passthrough(self): - from cpex.framework.hooks.prompts import PromptPosthookPayload - - class FakeResult(BaseModel): - messages: list = [] - description: str = "test" - - fake = FakeResult() - payload = PromptPosthookPayload(prompt_id="test", result=fake) - assert payload.result is fake - - -class TestExecutorPolicyEnforcement: - """Tests for policy enforcement in PluginExecutor.execute().""" - - @pytest.mark.asyncio - async def test_explicit_policy_filters_writable_fields(self): - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class ModifyingPlugin(Plugin): - async def test_hook(self, payload, context): - modified = payload.model_copy(update={"name": "new", "secret": "hacked"}) - return PluginResult(continue_processing=True, modified_payload=modified) - - config = PluginConfig(name="modifier", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - plugin = ModifyingPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old", secret="original") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="test_hook", - ) - assert result.modified_payload is not None - assert result.modified_payload.name == "new" - assert result.modified_payload.secret == "original" # filtered by policy - - @pytest.mark.asyncio - async def test_default_deny_rejects_modifications(self): - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class ModifyingPlugin(Plugin): - async def test_hook(self, payload, context): - modified = payload.model_copy(update={"name": "new"}) - return PluginResult(continue_processing=True, modified_payload=modified) - - config = PluginConfig(name="modifier", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - plugin = ModifyingPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - # No policies passed — default deny should reject all - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.default_hook_policy = "deny" - executor = PluginExecutor(hook_policies={}) - - payload = SamplePayload(name="old", secret="original") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="test_hook", - ) - # With deny policy, modifications should be rejected — modified_payload is None - assert result.modified_payload is None - - @pytest.mark.asyncio - async def test_explicit_policy_no_effective_change(self): - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class ModifyingPlugin(Plugin): - async def test_hook(self, payload, context): - # Only modify 'secret' which is NOT writable - modified = payload.model_copy(update={"secret": "hacked"}) - return PluginResult(continue_processing=True, modified_payload=modified) - - config = PluginConfig(name="modifier", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - plugin = ModifyingPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old", secret="original") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="test_hook", - ) - # apply_policy returns None because no writable fields changed — so modified_payload stays None - assert result.modified_payload is None - - @pytest.mark.asyncio - async def test_in_place_nested_mutation_caught_by_policy(self): - """Plugins that mutate nested dicts in place should not bypass policy filtering.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class InPlaceMutatingPlugin(Plugin): - async def test_hook(self, payload, context): - # Mutate the nested dict in place — bypasses frozen=True - # because Pydantic's frozen only protects top-level assignment. - payload.args["injected"] = "evil" - # Also mutate 'secret' (non-writable) via in-place nested trick - # and return the mutated payload as modified_payload. - return PluginResult( - continue_processing=True, - modified_payload=payload.model_copy( - update={"secret": "hacked", "args": {**payload.args, "injected": "evil"}} - ), - ) - - config = PluginConfig(name="mutator", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - plugin = InPlaceMutatingPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - # Only 'name' is writable — 'args' and 'secret' should be rejected - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old", args={"key": "value"}, secret="original") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="test_hook", - ) - # The snapshot preserves the original args, so the in-place - # mutation and 'secret' change are both caught by the policy diff - # and rejected. No writable field ('name') was changed, so - # modified_payload should be None. - assert result.modified_payload is None - - @pytest.mark.asyncio - async def test_enforce_early_return_uses_policy_filtered_payload(self): - """When a CONCURRENT plugin short-circuits after a prior plugin made - policy-approved modifications, the early return must carry those - filtered modifications via current_payload — not the raw result.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginMode, PluginResult - - class ModifyingPlugin(Plugin): - async def test_hook(self, payload, context): - modified = payload.model_copy(update={"name": "filtered", "secret": "hacked"}) - return PluginResult(continue_processing=True, modified_payload=modified) - - class BlockingPlugin(Plugin): - async def test_hook(self, payload, context): - return PluginResult(continue_processing=False) - - modify_config = PluginConfig(name="modifier", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - modify_plugin = ModifyingPlugin(modify_config) - modify_ref = PluginRef(modify_plugin) - modify_hook = HookRef("test_hook", modify_ref) - - block_config = PluginConfig( - name="blocker", kind="test.Plugin", version="1.0", hooks=["test_hook"], mode=PluginMode.CONCURRENT - ) - block_plugin = BlockingPlugin(block_config) - block_ref = PluginRef(block_plugin) - block_hook = HookRef("test_hook", block_ref) - - # Only 'name' is writable - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old", secret="original") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [modify_hook, block_hook], - payload, - global_ctx, - hook_type="test_hook", - ) - assert result.continue_processing is False - # The first plugin's writable modification must survive via current_payload - assert result.modified_payload is not None - assert result.modified_payload.name == "filtered" # writable — accepted - assert result.modified_payload.secret == "original" # non-writable — filtered - assert result.violation is None - - @pytest.mark.asyncio - async def test_enforce_early_return_carries_metadata(self): - """The early-return path must carry accumulated metadata from earlier - plugins, consistent with the normal return path.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginMode, PluginResult - - class MetadataPlugin(Plugin): - async def test_hook(self, payload, context): - return PluginResult(continue_processing=True, metadata={"source": "plugin_a"}) - - class BlockingPlugin(Plugin): - async def test_hook(self, payload, context): - return PluginResult(continue_processing=False) - - meta_config = PluginConfig(name="meta", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - meta_plugin = MetadataPlugin(meta_config) - meta_ref = PluginRef(meta_plugin) - meta_hook = HookRef("test_hook", meta_ref) - - block_config = PluginConfig( - name="blocker", kind="test.Plugin", version="1.0", hooks=["test_hook"], mode=PluginMode.CONCURRENT - ) - block_plugin = BlockingPlugin(block_config) - block_ref = PluginRef(block_plugin) - block_hook = HookRef("test_hook", block_ref) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"name"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [meta_hook, block_hook], - payload, - global_ctx, - hook_type="test_hook", - ) - assert result.continue_processing is False - assert result.metadata == {"source": "plugin_a"} - - @pytest.mark.asyncio - async def test_enforce_early_return_deny_default_rejects_all(self): - """When default=deny and a CONCURRENT plugin short-circuits with modifications, - all modifications must be rejected (modified_payload=None).""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginMode, PluginResult - - class BlockingPlugin(Plugin): - async def test_hook(self, payload, context): - modified = payload.model_copy(update={"name": "new"}) - return PluginResult(continue_processing=False, modified_payload=modified) - - config = PluginConfig( - name="blocker", kind="test.Plugin", version="1.0", hooks=["test_hook"], mode=PluginMode.CONCURRENT - ) - plugin = BlockingPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - # No policies, default deny - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.default_hook_policy = "deny" - executor = PluginExecutor(hook_policies={}) - - payload = SamplePayload(name="old", secret="original") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="test_hook", - ) - assert result.continue_processing is False - assert result.modified_payload is None # all modifications rejected - - -class TestCrossTypePolicyHandling: - """Tests for cross-type payload results (e.g. HTTP hooks).""" - - @pytest.mark.asyncio - async def test_cross_type_result_accepted_when_policy_exists(self): - """When modified_payload is a different PluginPayload subtype from the - input, the policy's presence authorises the hook and the result is accepted.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginPayload, PluginResult - - class DifferentResult(PluginPayload): - granted: bool = True - reason: str = "ok" - - class CrossTypePlugin(Plugin): - async def test_hook(self, payload, context): - return PluginResult(continue_processing=True, modified_payload=DifferentResult()) - - config = PluginConfig(name="cross", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - plugin = CrossTypePlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset({"granted", "reason"}))} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute([hook_ref], payload, global_ctx, hook_type="test_hook") - assert result.modified_payload is not None - assert result.modified_payload.granted is True - - @pytest.mark.asyncio - async def test_cross_type_dict_result_accepted_when_policy_exists(self): - """dict results (e.g. http_auth_resolve_user) are accepted when a policy exists.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class DictResultPlugin(Plugin): - async def test_hook(self, payload, context): - return PluginResult(continue_processing=True, modified_payload={"email": "user@example.com"}) - - config = PluginConfig(name="auth", kind="test.Plugin", version="1.0", hooks=["test_hook"]) - plugin = DictResultPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("test_hook", ref) - - policies = {"test_hook": HookPayloadPolicy(writable_fields=frozenset())} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="old") - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute([hook_ref], payload, global_ctx, hook_type="test_hook") - assert result.modified_payload == {"email": "user@example.com"} - - @pytest.mark.asyncio - async def test_deny_default_snapshots_payload_for_in_place_isolation(self): - """When default=deny, in-place nested mutations must not persist on the live payload.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class InPlaceMutator(Plugin): - async def no_policy_hook(self, payload, context): - payload.args["injected"] = "evil" - return PluginResult(continue_processing=True, modified_payload=None) - - config = PluginConfig(name="mutator", kind="test.Plugin", version="1.0", hooks=["no_policy_hook"]) - plugin = InPlaceMutator(config) - ref = PluginRef(plugin) - hook_ref = HookRef("no_policy_hook", ref) - - # No policy for this hook, default=deny - with patch("cpex.framework.manager.settings") as mock_settings: - mock_settings.default_hook_policy = "deny" - executor = PluginExecutor(hook_policies={}) - - payload = SamplePayload(name="old", args={"key": "value"}) - global_ctx = GlobalContext(request_id="1") - - result, _ = await executor.execute([hook_ref], payload, global_ctx, hook_type="no_policy_hook") - # The plugin mutated args in-place, but the deep-copy snapshot means - # the original payload passed to the next plugin (or returned) is clean - assert result.modified_payload is None, "deny-default should reject all modifications" - assert payload.args == {"key": "value"}, "Original payload must not be mutated" - - -class TestBorgPolicyBackfill: - """Tests for PluginManager Borg pattern policy injection.""" - - def test_get_plugin_manager_injects_policies(self, monkeypatch, tmp_path): - """Verify get_plugin_manager() always injects hook policies.""" - import cpex.framework as fw - from cpex.framework.settings import settings as plugin_settings - from tests.unit.cpex.fixtures.common.policy import HOOK_PAYLOAD_POLICIES - - config_file = tmp_path / "plugins.yaml" - config_file.write_text("plugin_settings:\n plugin_timeout: 30\nplugin_dirs: []\nplugins: []\n") - - monkeypatch.setenv("PLUGINS_ENABLED", "true") - monkeypatch.setenv("PLUGINS_CONFIG_FILE", str(config_file)) - - # Reset singleton state - fw.PluginManager.reset() - fw._plugin_manager = None - plugin_settings.cache_clear() - - pm = fw.get_plugin_manager(hook_policies=HOOK_PAYLOAD_POLICIES) - assert pm is not None - assert pm._executor.hook_policies, "get_plugin_manager() should inject hook policies" - - # Verify known policy keys are present - assert "tool_pre_invoke" in pm._executor.hook_policies - assert "prompt_post_fetch" in pm._executor.hook_policies - - def test_service_via_get_plugin_manager_has_policies(self, monkeypatch, tmp_path): - """Verify that services using get_plugin_manager() get policies regardless of creation order.""" - import cpex.framework as fw - from cpex.framework.settings import settings as plugin_settings - from tests.unit.cpex.fixtures.common.policy import HOOK_PAYLOAD_POLICIES - - config_file = tmp_path / "plugins.yaml" - config_file.write_text("plugin_settings:\n plugin_timeout: 30\nplugin_dirs: []\nplugins: []\n") - - monkeypatch.setenv("PLUGINS_ENABLED", "true") - monkeypatch.setenv("PLUGINS_CONFIG_FILE", str(config_file)) - - # Reset state - fw.PluginManager.reset() - fw._plugin_manager = None - plugin_settings.cache_clear() - - # Simulate service creating manager via get_plugin_manager - pm1 = fw.get_plugin_manager(hook_policies=HOOK_PAYLOAD_POLICIES) - - # Simulate another access (e.g. from another service) - pm2 = fw.get_plugin_manager(hook_policies=HOOK_PAYLOAD_POLICIES) - - # Both should share the same executor with policies - assert pm1 is pm2 - assert pm1._executor.hook_policies - - @pytest.mark.asyncio - async def test_policy_enforcement_through_manager(self, monkeypatch, tmp_path): - """Integration test: policies enforced through PluginManager.execute flow.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.hooks.policies import HookPayloadPolicy - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class InjectPlugin(Plugin): - async def tool_pre_invoke(self, payload, context): - modified = payload.model_copy( - update={"name": "injected", "args": {"injected": "true"}, "secret": "hacked"} - ) - return PluginResult(continue_processing=True, modified_payload=modified) - - # Set up executor with tool_pre_invoke policy - policies = { - "tool_pre_invoke": HookPayloadPolicy(writable_fields=frozenset({"name", "args"})), - } - executor = PluginExecutor(hook_policies=policies) - - config = PluginConfig(name="injector", kind="test.Plugin", version="1.0", hooks=["tool_pre_invoke"]) - plugin = InjectPlugin(config) - ref = PluginRef(plugin) - hook_ref = HookRef("tool_pre_invoke", ref) - - payload = SamplePayload(name="original", args={}, secret="safe") - global_ctx = GlobalContext(request_id="test-1") - - result, _ = await executor.execute( - [hook_ref], - payload, - global_ctx, - hook_type="tool_pre_invoke", - ) - - assert result.modified_payload is not None - assert result.modified_payload.name == "injected" - assert result.modified_payload.args == {"injected": "true"} - assert result.modified_payload.secret == "safe" # Policy filtered this out - - - @pytest.mark.asyncio - async def test_tool_pre_invoke_empty_args_modification_preserved_through_executor(self): - """Regression test for the tool_pre_invoke executor path. - - A plugin receives CoW-wrapped args containing only specific fields, - strips them all, and returns a payload with args={}. The executor should - preserve that empty args modification instead of dropping it as - "unchanged". - """ - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.hooks.policies import HookPayloadPolicy - from cpex.framework.hooks.tools import ToolPreInvokePayload - from cpex.framework.manager import PluginExecutor - from cpex.framework.memory import CopyOnWriteDict - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - seen_arg_types = [] - - class StripWxoArgsPlugin(Plugin): - async def tool_pre_invoke(self, payload, context): - seen_arg_types.append(type(payload.args)) - cleaned_args = {k: v for k, v in payload.args.items() if not k.startswith("wxo_")} - modified = payload.model_copy(update={"args": cleaned_args}) - return PluginResult(continue_processing=True, modified_payload=modified) - - policies = { - "tool_pre_invoke": HookPayloadPolicy(writable_fields=frozenset({"args"})), - } - executor = PluginExecutor(hook_policies=policies) - - config = PluginConfig(name="stripper", kind="test.Plugin", version="1.0", hooks=["tool_pre_invoke"]) - plugin = StripWxoArgsPlugin(config) - hook_ref = HookRef("tool_pre_invoke", PluginRef(plugin)) - - payload = ToolPreInvokePayload( - name="list_all_secrets", - args={ - "wxo_connection_id": "", - "wxo_auth": "fake-token", - "wxo_environment_id": "draft", - }, - ) - global_ctx = GlobalContext(request_id="tool-pre-empty-args") - - result, _ = await executor.execute([hook_ref], payload, global_ctx, hook_type="tool_pre_invoke") - - assert seen_arg_types == [CopyOnWriteDict] - assert result.modified_payload is not None - assert result.modified_payload == ToolPreInvokePayload(name="list_all_secrets", args={}) - assert payload.args == { - "wxo_connection_id": "", - "wxo_auth": "fake-token", - "wxo_environment_id": "draft", - } - -class TestMultiPluginDictChain: - """Tests for multi-plugin chains where an earlier plugin returns a dict payload.""" - - @pytest.mark.asyncio - async def test_dict_payload_deep_copied_for_next_plugin(self): - """When plugin 1 returns a dict, the next plugin receives a deep-copied - dict without crashing on model_copy (which dicts don't have).""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class AuthPlugin(Plugin): - async def auth_hook(self, payload, context): - return PluginResult( - continue_processing=True, modified_payload={"email": "user@test.com", "role": "admin"} - ) - - class AuditPlugin(Plugin): - async def auth_hook(self, payload, context): - # Second plugin just passes through - return PluginResult(continue_processing=True) - - auth_config = PluginConfig(name="auth", kind="test.Plugin", version="1.0", hooks=["auth_hook"]) - audit_config = PluginConfig(name="audit", kind="test.Plugin", version="1.0", hooks=["auth_hook"]) - - auth_plugin = AuthPlugin(auth_config) - audit_plugin = AuditPlugin(audit_config) - - hook_refs = [ - HookRef("auth_hook", PluginRef(auth_plugin)), - HookRef("auth_hook", PluginRef(audit_plugin)), - ] - - policies = {"auth_hook": HookPayloadPolicy(writable_fields=frozenset())} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="original") - global_ctx = GlobalContext(request_id="multi-1") - - result, _ = await executor.execute(hook_refs, payload, global_ctx, hook_type="auth_hook") - assert result.continue_processing is True - assert result.modified_payload == {"email": "user@test.com", "role": "admin"} - - @pytest.mark.asyncio - async def test_dict_to_dict_accepted_without_apply_policy(self): - """When effective_payload is a dict and plugin also returns a dict, - the result is accepted directly (not routed through apply_policy).""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class FirstAuth(Plugin): - async def auth_hook(self, payload, context): - return PluginResult(continue_processing=True, modified_payload={"email": "first@test.com"}) - - class SecondAuth(Plugin): - async def auth_hook(self, payload, context): - return PluginResult( - continue_processing=True, modified_payload={"email": "second@test.com", "enriched": True} - ) - - first_config = PluginConfig(name="first", kind="test.Plugin", version="1.0", hooks=["auth_hook"]) - second_config = PluginConfig(name="second", kind="test.Plugin", version="1.0", hooks=["auth_hook"]) - - hook_refs = [ - HookRef("auth_hook", PluginRef(FirstAuth(first_config))), - HookRef("auth_hook", PluginRef(SecondAuth(second_config))), - ] - - policies = {"auth_hook": HookPayloadPolicy(writable_fields=frozenset())} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="original") - global_ctx = GlobalContext(request_id="multi-2") - - result, _ = await executor.execute(hook_refs, payload, global_ctx, hook_type="auth_hook") - assert result.continue_processing is True - assert result.modified_payload == {"email": "second@test.com", "enriched": True} - - @pytest.mark.asyncio - async def test_empty_dict_payload_not_dropped_by_truthiness(self): - """An empty dict returned by a plugin must not be replaced by the - original payload due to falsy truthiness evaluation.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class EmptyDictPlugin(Plugin): - async def auth_hook(self, payload, context): - return PluginResult(continue_processing=True, modified_payload={}) - - class PassthroughPlugin(Plugin): - """Runs after EmptyDictPlugin; receives the empty dict if chaining works.""" - - async def auth_hook(self, payload, context): - return PluginResult(continue_processing=True) - - hook_refs = [ - HookRef( - "auth_hook", - PluginRef( - EmptyDictPlugin(PluginConfig(name="empty", kind="test.Plugin", version="1.0", hooks=["auth_hook"])) - ), - ), - HookRef( - "auth_hook", - PluginRef( - PassthroughPlugin(PluginConfig(name="pass", kind="test.Plugin", version="1.0", hooks=["auth_hook"])) - ), - ), - ] - - policies = {"auth_hook": HookPayloadPolicy(writable_fields=frozenset())} - executor = PluginExecutor(hook_policies=policies) - - payload = SamplePayload(name="original") - global_ctx = GlobalContext(request_id="multi-3") - - result, _ = await executor.execute(hook_refs, payload, global_ctx, hook_type="auth_hook") - assert result.modified_payload == {}, "Empty dict should be preserved, not replaced by original payload" - - -@pytest.mark.asyncio -async def test_http_auth_permission_result_includes_decision_plugin_provenance(): - """Permission hook decisions should carry deciding plugin identity even with empty plugin metadata.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.hooks.http import HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResultPayload - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class DecisionPlugin(Plugin): - async def http_auth_check_permission(self, payload, context): - return PluginResult( - continue_processing=True, - modified_payload=HttpAuthCheckPermissionResultPayload(granted=False, reason="Denied by policy"), - metadata={}, - ) - - config = PluginConfig( - name="decision-plugin", kind="test.Plugin", version="1.0", hooks=["http_auth_check_permission"] - ) - hook_ref = HookRef("http_auth_check_permission", PluginRef(DecisionPlugin(config))) - - executor = PluginExecutor( - hook_policies={"http_auth_check_permission": HookPayloadPolicy(writable_fields=frozenset({"reason"}))} - ) - payload = HttpAuthCheckPermissionPayload( - user_email="user@example.com", permission="tools.read", resource_type="tool" - ) - result, _ = await executor.execute( - [hook_ref], payload, GlobalContext(request_id="decision-1"), hook_type="http_auth_check_permission" - ) - - assert result.modified_payload is not None - assert result.modified_payload.granted is False - assert result.metadata["_decision_plugin"] == "decision-plugin" - - -@pytest.mark.asyncio -async def test_http_auth_permission_provenance_overrides_forged_metadata_from_decider(): - """Manager-owned provenance must overwrite forged plugin metadata keys.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.hooks.http import HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResultPayload - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class ForgingDecisionPlugin(Plugin): - async def http_auth_check_permission(self, payload, context): - return PluginResult( - continue_processing=True, - modified_payload=HttpAuthCheckPermissionResultPayload(granted=False, reason="Denied by policy"), - metadata={"_decision_plugin": "spoofed-name", "note": "kept"}, - ) - - config = PluginConfig( - name="real-decision-plugin", kind="test.Plugin", version="1.0", hooks=["http_auth_check_permission"] - ) - hook_ref = HookRef("http_auth_check_permission", PluginRef(ForgingDecisionPlugin(config))) - executor = PluginExecutor( - hook_policies={"http_auth_check_permission": HookPayloadPolicy(writable_fields=frozenset({"reason"}))} - ) - payload = HttpAuthCheckPermissionPayload( - user_email="user@example.com", permission="tools.read", resource_type="tool" - ) - - result, _ = await executor.execute( - [hook_ref], payload, GlobalContext(request_id="decision-forge-1"), hook_type="http_auth_check_permission" - ) - - assert result.metadata["_decision_plugin"] == "real-decision-plugin" - assert result.metadata["note"] == "kept" - - -@pytest.mark.asyncio -async def test_http_auth_permission_provenance_uses_actual_decider_in_multi_plugin_chain(): - """Metadata-only plugins must not control provenance when a later plugin decides.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.hooks.http import HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResultPayload - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginResult - - class MetadataOnlyPlugin(Plugin): - async def http_auth_check_permission(self, payload, context): - return PluginResult( - continue_processing=True, metadata={"_decision_plugin": "metadata-only-plugin", "source": "plugin-a"} - ) - - class DecisionPluginB(Plugin): - async def http_auth_check_permission(self, payload, context): - return PluginResult( - continue_processing=True, - modified_payload=HttpAuthCheckPermissionResultPayload(granted=True, reason="Allowed by policy"), - metadata={}, - ) - - hook_refs = [ - HookRef( - "http_auth_check_permission", - PluginRef( - MetadataOnlyPlugin( - PluginConfig( - name="metadata-a", kind="test.Plugin", version="1.0", hooks=["http_auth_check_permission"] - ) - ) - ), - ), - HookRef( - "http_auth_check_permission", - PluginRef( - DecisionPluginB( - PluginConfig( - name="decision-b", kind="test.Plugin", version="1.0", hooks=["http_auth_check_permission"] - ) - ) - ), - ), - ] - executor = PluginExecutor( - hook_policies={"http_auth_check_permission": HookPayloadPolicy(writable_fields=frozenset({"reason"}))} - ) - payload = HttpAuthCheckPermissionPayload( - user_email="user@example.com", permission="tools.read", resource_type="tool" - ) - - result, _ = await executor.execute( - hook_refs, payload, GlobalContext(request_id="decision-forge-2"), hook_type="http_auth_check_permission" - ) - - assert result.modified_payload is not None - assert result.modified_payload.granted is True - assert result.metadata["_decision_plugin"] == "decision-b" - assert result.metadata["source"] == "plugin-a" - - -@pytest.mark.asyncio -async def test_http_auth_permission_enforce_short_circuit_records_decision_plugin(): - """CONCURRENT short-circuit path should still persist authoritative decision provenance.""" - from cpex.framework.base import HookRef, Plugin, PluginRef - from cpex.framework.hooks.http import HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResultPayload - from cpex.framework.manager import PluginExecutor - from cpex.framework.models import GlobalContext, PluginConfig, PluginMode, PluginResult - - class DecisionPlugin(Plugin): - async def http_auth_check_permission(self, payload, context): - return PluginResult( - continue_processing=True, - modified_payload=HttpAuthCheckPermissionResultPayload(granted=False, reason="Denied"), - metadata={}, - ) - - class EnforceBlockPlugin(Plugin): - async def http_auth_check_permission(self, payload, context): - return PluginResult(continue_processing=False, metadata={}) - - decision_config = PluginConfig( - name="decision-plugin", kind="test.Plugin", version="1.0", hooks=["http_auth_check_permission"] - ) - block_config = PluginConfig( - name="enforce-block-plugin", - kind="test.Plugin", - version="1.0", - hooks=["http_auth_check_permission"], - mode=PluginMode.CONCURRENT, - ) - hook_refs = [ - HookRef("http_auth_check_permission", PluginRef(DecisionPlugin(decision_config))), - HookRef("http_auth_check_permission", PluginRef(EnforceBlockPlugin(block_config))), - ] - - executor = PluginExecutor( - hook_policies={"http_auth_check_permission": HookPayloadPolicy(writable_fields=frozenset({"reason"}))} - ) - payload = HttpAuthCheckPermissionPayload( - user_email="user@example.com", permission="tools.execute", resource_type="tool" - ) - - result, _ = await executor.execute( - hook_refs, payload, GlobalContext(request_id="decision-short-circuit"), hook_type="http_auth_check_permission" - ) - - assert result.continue_processing is False - assert result.metadata["_decision_plugin"] == "decision-plugin" diff --git a/tests/unit/cpex/framework/test_registry.py b/tests/unit/cpex/framework/test_registry.py deleted file mode 100644 index cde642a0..00000000 --- a/tests/unit/cpex/framework/test_registry.py +++ /dev/null @@ -1,351 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_registry.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for plugin registry. -""" - -# Standard -from unittest.mock import AsyncMock, patch - -# Third-Party -import pytest - -from cpex.framework import PluginConfig, PromptHookType, ToolHookType - -# First-Party -from cpex.framework.loader.config import ConfigLoader -from cpex.framework.loader.plugin import PluginLoader -from cpex.framework.registry import PluginInstanceRegistry -from tests.unit.cpex.fixtures.plugins.simple import SimplePromptPlugin - - -@pytest.mark.asyncio -async def test_registry_register(): - """Load a plugin with the plugin loader.""" - config = ConfigLoader.load_config(config="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - loader = PluginLoader() - loader.append_to_search_path(config.plugin_dirs) - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - registry = PluginInstanceRegistry() - registry.register(plugin) - - all_plugins = registry.get_all_plugins() - assert len(all_plugins) == 1 - assert registry.get_plugin("ReplaceBadWordsPlugin") - assert registry.get_plugin("SomeNonExistentPlugin") is None - - registry.unregister("ReplaceBadWordsPlugin") - assert registry.plugin_count == 0 - - registry.unregister("SomePluginThatDoesntExist") - - all_plugins = registry.get_all_plugins() - assert len(all_plugins) == 0 - - -@pytest.mark.asyncio -async def test_registry_duplicate_plugin_registration(): - """Test that registering a plugin twice raises ValueError.""" - config = ConfigLoader.load_config(config="./tests/unit/cpex/fixtures/configs/valid_single_plugin.yaml") - loader = PluginLoader() - loader.append_to_search_path(config.plugin_dirs) - plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) - registry = PluginInstanceRegistry() - - # First registration should work - registry.register(plugin) - assert registry.plugin_count == 1 - - # Second registration should raise ValueError (line 77) - with pytest.raises(ValueError, match="Plugin .* already registered"): - registry.register(plugin) - - # Clean up - registry.unregister(plugin.name) - assert registry.plugin_count == 0 - - -@pytest.mark.asyncio -async def test_registry_priority_sorting(): - """Test plugin priority sorting and caching.""" - registry = PluginInstanceRegistry() - - # Create plugins with different priorities - low_priority_config = PluginConfig( - name="LowPriority", - description="Low priority plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - priority=300, # High number = low priority - config={}, - ) - - high_priority_config = PluginConfig( - name="HighPriority", - description="High priority plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - priority=50, # Low number = high priority - config={}, - ) - - # Create plugin instances - low_priority_plugin = SimplePromptPlugin(low_priority_config) - high_priority_plugin = SimplePromptPlugin(high_priority_config) - - # Register plugins in reverse priority order - registry.register(low_priority_plugin) - registry.register(high_priority_plugin) - - # Get plugins for hook - should be sorted by priority (lines 131-134) - hook_plugins = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH) - assert len(hook_plugins) == 2 - assert hook_plugins[0].plugin_ref.name == "HighPriority" # Lower number = higher priority - assert hook_plugins[1].plugin_ref.name == "LowPriority" - - # Test priority cache - calling again should use cached result - cached_plugins = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH) - assert cached_plugins == hook_plugins - - # Clean up - registry.unregister("LowPriority") - registry.unregister("HighPriority") - assert registry.plugin_count == 0 - - -@pytest.mark.asyncio -async def test_registry_has_hooks_for(): - """Test has_hooks_for method for hook existence checking.""" - registry = PluginInstanceRegistry() - - assert not registry.has_hooks_for(PromptHookType.PROMPT_PRE_FETCH) - - plugin_config = PluginConfig( - name="TestPlugin", - description="Test plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - - plugin = SimplePromptPlugin(plugin_config) - registry.register(plugin) - - assert registry.has_hooks_for(PromptHookType.PROMPT_PRE_FETCH) - - registry.unregister("TestPlugin") - - assert not registry.has_hooks_for(PromptHookType.PROMPT_PRE_FETCH) - - -@pytest.mark.asyncio -async def test_registry_hook_filtering(): - """Test getting plugins for different hooks.""" - registry = PluginInstanceRegistry() - - # Create plugin with specific hooks - pre_fetch_config = PluginConfig( - name="PreFetchPlugin", - description="Pre-fetch plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - - post_fetch_config = PluginConfig( - name="PostFetchPlugin", - description="Post-fetch plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_POST_FETCH], - config={}, - ) - - pre_fetch_plugin = SimplePromptPlugin(pre_fetch_config) - post_fetch_plugin = SimplePromptPlugin(post_fetch_config) - - registry.register(pre_fetch_plugin) - registry.register(post_fetch_plugin) - - # Test hook filtering - pre_plugins = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH) - post_plugins = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_POST_FETCH) - tool_plugins = registry.get_hook_refs_for_hook(ToolHookType.TOOL_PRE_INVOKE) - - assert len(pre_plugins) == 1 - assert pre_plugins[0].plugin_ref.name == "PreFetchPlugin" - - assert len(post_plugins) == 1 - assert post_plugins[0].plugin_ref.name == "PostFetchPlugin" - - assert len(tool_plugins) == 0 # No plugins for this hook - - # Clean up - registry.unregister("PreFetchPlugin") - registry.unregister("PostFetchPlugin") - - -@pytest.mark.asyncio -async def test_registry_shutdown(): - """Test registry shutdown functionality (lines 155-162).""" - registry = PluginInstanceRegistry() - - # Create mock plugins with shutdown methods - mock_plugin1 = SimplePromptPlugin( - PluginConfig( - name="Plugin1", - description="Test plugin 1", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - ) - - mock_plugin2 = SimplePromptPlugin( - PluginConfig( - name="Plugin2", - description="Test plugin 2", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_POST_FETCH], - config={}, - ) - ) - - # Mock the shutdown methods - mock_plugin1.shutdown = AsyncMock() - mock_plugin2.shutdown = AsyncMock() - - registry.register(mock_plugin1) - registry.register(mock_plugin2) - - assert registry.plugin_count == 2 - - # Test shutdown - await registry.shutdown() - - # Verify shutdown was called on both plugins - mock_plugin1.shutdown.assert_called_once() - mock_plugin2.shutdown.assert_called_once() - - # Verify registry is cleared - assert registry.plugin_count == 0 - assert len(registry.get_all_plugins()) == 0 - assert len(registry._hooks) == 0 - assert len(registry._priority_cache) == 0 - - -@pytest.mark.asyncio -async def test_registry_shutdown_with_error(): - """Test registry shutdown when plugin shutdown fails (lines 158-159).""" - registry = PluginInstanceRegistry() - - # Create mock plugin that fails during shutdown - failing_plugin = SimplePromptPlugin( - PluginConfig( - name="FailingPlugin", - description="Plugin that fails shutdown", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - ) - - # Mock shutdown to raise an exception - failing_plugin.shutdown = AsyncMock(side_effect=RuntimeError("Shutdown failed")) - - registry.register(failing_plugin) - assert registry.plugin_count == 1 - - # Shutdown should handle the error gracefully - with patch("cpex.framework.registry.logger") as mock_logger: - await registry.shutdown() - - # Verify error was logged - mock_logger.error.assert_called_once() - error_call = mock_logger.error.call_args[0][0] - assert "Error shutting down plugin FailingPlugin" in error_call - - # Registry should still be cleared despite the error - assert registry.plugin_count == 0 - - -@pytest.mark.asyncio -async def test_registry_edge_cases(): - """Test various edge cases for full coverage.""" - registry = PluginInstanceRegistry() - - # Test getting plugin that doesn't exist - assert registry.get_plugin("NonExistent") is None - - # Test unregistering plugin that doesn't exist (line 100-101) - registry.unregister("NonExistent") # Should do nothing - assert registry.plugin_count == 0 - - # Test getting hooks for empty registry - empty_hooks = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH) - assert len(empty_hooks) == 0 - - # Test get_all_plugins when empty - assert len(registry.get_all_plugins()) == 0 - - -@pytest.mark.asyncio -async def test_registry_cache_invalidation(): - """Test that priority cache is invalidated correctly.""" - registry = PluginInstanceRegistry() - - plugin_config = PluginConfig( - name="TestPlugin", - description="Test plugin", - author="Test", - version="1.0", - tags=["test"], - kind="test.Plugin", - hooks=[PromptHookType.PROMPT_PRE_FETCH], - config={}, - ) - - plugin = SimplePromptPlugin(plugin_config) - - # Register plugin - registry.register(plugin) - - # Get plugins for hook (populates cache) - hooks1 = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH) - assert len(hooks1) == 1 - - # Cache should be populated - assert PromptHookType.PROMPT_PRE_FETCH in registry._priority_cache - - # Unregister plugin (should invalidate cache) - registry.unregister("TestPlugin") - - # Cache should be cleared for this hook type - hooks2 = registry.get_hook_refs_for_hook(PromptHookType.PROMPT_PRE_FETCH) - assert len(hooks2) == 0 diff --git a/tests/unit/cpex/framework/test_resource_hooks.py b/tests/unit/cpex/framework/test_resource_hooks.py deleted file mode 100644 index cef6b41e..00000000 --- a/tests/unit/cpex/framework/test_resource_hooks.py +++ /dev/null @@ -1,502 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_resource_hooks.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Mihai Criveti - -Tests for resource hook functionality in the plugin framework. -""" - -# Standard -from unittest.mock import AsyncMock, MagicMock, patch - -# Third-Party -import pytest - -# Registry is imported for mocking -from cpex.framework import ( - GlobalContext, - OnError, - Plugin, - PluginCondition, - PluginConfig, - PluginContext, - PluginError, - PluginManager, - PluginMode, - PluginViolation, - ResourceHookType, - ResourcePostFetchPayload, - ResourcePostFetchResult, - ResourcePreFetchPayload, - ResourcePreFetchResult, -) - -# First-Party -from cpex.framework.base import PluginRef -from tests.unit.cpex.fixtures.common.models import ResourceContent - - -class TestResourceHooks: - """Test resource pre/post fetch hooks.""" - - def test_resource_pre_fetch_payload(self): - """Test ResourcePreFetchPayload creation and attributes.""" - payload = ResourcePreFetchPayload(uri="file:///test.txt", metadata={"cache": True}) - assert payload.uri == "file:///test.txt" - assert payload.metadata == {"cache": True} - - def test_resource_post_fetch_payload(self): - """Test ResourcePostFetchPayload creation and attributes.""" - content = ResourceContent(type="resource", id="123", uri="file:///test.txt", text="Test content") - payload = ResourcePostFetchPayload(uri="file:///test.txt", content=content) - assert payload.uri == "file:///test.txt" - assert payload.content == content - assert payload.content.text == "Test content" - - @pytest.mark.asyncio - async def test_plugin_resource_pre_fetch_default(self): - """Test default resource_pre_fetch implementation.""" - config = PluginConfig( - name="test_resource", - description="Test resource plugin", - author="test", - kind="test.Plugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["test"], - ) - plugin = Plugin(config) - payload = ResourcePreFetchPayload(uri="file:///test.txt", metadata={}) - context = PluginContext(global_context=GlobalContext(request_id="test-123")) - - with pytest.raises(AttributeError, match="'Plugin' object has no attribute 'resource_pre_fetch'"): - await plugin.resource_pre_fetch(payload, context) - - @pytest.mark.asyncio - async def test_plugin_resource_post_fetch_default(self): - """Test default resource_post_fetch implementation.""" - config = PluginConfig( - name="test_resource", - description="Test resource plugin", - author="test", - kind="test.Plugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_POST_FETCH], - tags=["test"], - ) - plugin = Plugin(config) - content = ResourceContent(type="resource", id="123", uri="file:///test.txt", text="Test content") - payload = ResourcePostFetchPayload(uri="file:///test.txt", content=content) - context = PluginContext(global_context=GlobalContext(request_id="test-123")) - - with pytest.raises(AttributeError, match="'Plugin' object has no attribute 'resource_post_fetch'"): - await plugin.resource_post_fetch(payload, context) - - @pytest.mark.asyncio - async def test_resource_hook_blocking(self): - """Test resource hook that blocks processing.""" - - class BlockingResourcePlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - return ResourcePreFetchResult( - continue_processing=False, - violation=PluginViolation( - reason="Protocol not allowed", - code="PROTOCOL_BLOCKED", - description="file:// protocol is blocked", - details={"protocol": "file", "uri": payload.uri}, - ), - ) - - config = PluginConfig( - name="blocking_resource", - description="Blocking resource plugin", - author="test", - kind="test.BlockingPlugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["test"], - mode=PluginMode.CONCURRENT, - ) - plugin = BlockingResourcePlugin(config) - payload = ResourcePreFetchPayload(uri="file:///etc/passwd", metadata={}) - context = PluginContext(global_context=GlobalContext(request_id="test-123")) - - result = await plugin.resource_pre_fetch(payload, context) - - assert result.continue_processing is False - assert result.violation is not None - assert result.violation.code == "PROTOCOL_BLOCKED" - assert result.violation.reason == "Protocol not allowed" - - @pytest.mark.asyncio - async def test_resource_content_modification(self): - """Test resource post-fetch content modification.""" - - class ContentFilterPlugin(Plugin): - async def resource_post_fetch(self, payload, context): - # Modify content to redact sensitive data - modified_text = payload.content.text.replace("password: secret123", "password: [REDACTED]") - modified_content = ResourceContent( - type=payload.content.type, - id=payload.content.id, - uri=payload.content.uri, - text=modified_text, - ) - modified_payload = ResourcePostFetchPayload( - uri=payload.uri, - content=modified_content, - ) - return ResourcePostFetchResult( - continue_processing=True, - modified_payload=modified_payload, - ) - - config = PluginConfig( - name="content_filter", - description="Content filter plugin", - author="test", - kind="test.FilterPlugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_POST_FETCH], - tags=["filter"], - ) - plugin = ContentFilterPlugin(config) - content = ResourceContent( - type="resource", - id="123", - uri="test://config", - text="Database config:\npassword: secret123\nport: 5432", - ) - payload = ResourcePostFetchPayload(uri="test://config", content=content) - context = PluginContext(global_context=GlobalContext(request_id="test-123")) - - result = await plugin.resource_post_fetch(payload, context) - - assert result.continue_processing is True - assert result.modified_payload is not None - assert "[REDACTED]" in result.modified_payload.content.text - assert "secret123" not in result.modified_payload.content.text - - @pytest.mark.asyncio - async def test_resource_hook_with_conditions(self): - """Test resource hooks with conditions.""" - - class ConditionalResourcePlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - # Only process if conditions match - return ResourcePreFetchResult( - continue_processing=False, - violation=PluginViolation( - reason="Blocked by condition", - code="CONDITION_BLOCK", - ), - ) - - config = PluginConfig( - name="conditional_resource", - description="Conditional resource plugin", - author="test", - kind="test.ConditionalPlugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["conditional"], - conditions=[ - PluginCondition( - resources=["file://*", "ftp://*"], - server_ids=["server1"], - ) - ], - ) - plugin = ConditionalResourcePlugin(config) - ref = PluginRef(plugin) - - # Test that conditions are accessible - assert ref.conditions is not None - assert len(ref.conditions) == 1 - assert "file://*" in ref.conditions[0].resources - - -class TestResourceHookIntegration: - """Test resource hook integration with plugin manager.""" - - @pytest.fixture(autouse=True) - def clear_plugin_manager_state(self): - """Clear the PluginManager shared state before and after each test.""" - # Clear before test - # First-Party - from cpex.framework.manager import PluginManager - - PluginManager._PluginManager__shared_state.clear() - yield - # Clear after test - PluginManager._PluginManager__shared_state.clear() - - @pytest.mark.asyncio - async def test_manager_resource_pre_fetch(self): - """Test plugin manager resource_pre_fetch execution.""" - with patch("cpex.framework.manager.PluginInstanceRegistry") as MockRegistry: - with patch("cpex.framework.loader.config.ConfigLoader.load_config") as MockConfig: - # Create a proper mock plugin with all required attributes - mock_plugin_obj = MagicMock() - mock_plugin_obj.name = "test_plugin" - mock_plugin_obj.priority = 50 - mock_plugin_obj.mode = PluginMode.CONCURRENT - mock_plugin_obj.conditions = [] - mock_plugin_obj.resource_pre_fetch = AsyncMock( - return_value=ResourcePreFetchResult( - continue_processing=True, - modified_payload=None, - ) - ) - - # Create a PluginRef-like mock - mock_ref = MagicMock() - mock_ref._plugin = mock_plugin_obj - mock_ref.plugin = mock_plugin_obj - mock_ref.name = "test_plugin" - mock_ref.priority = 50 - mock_ref.mode = PluginMode.CONCURRENT - mock_ref.conditions = [] - mock_ref.uuid = "test-uuid" - - MockRegistry.return_value.get_plugins_for_hook.return_value = [mock_ref] - - # Mock config - mock_config = MagicMock() - mock_config.plugin_settings = MagicMock() - MockConfig.return_value = mock_config - - manager = PluginManager("test_config.yaml") - manager._registry = MockRegistry.return_value - manager._initialized = True - - payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) - global_context = GlobalContext(request_id="test-123") - - result, contexts = await manager.invoke_hook( - ResourceHookType.RESOURCE_PRE_FETCH, payload, global_context - ) - - assert result.continue_processing is True - MockRegistry.return_value.get_hook_refs_for_hook.assert_called_with( - hook_type=ResourceHookType.RESOURCE_PRE_FETCH - ) - - @pytest.mark.asyncio - async def test_manager_resource_post_fetch(self): - """Test plugin manager resource_post_fetch execution.""" - # First-Party - from cpex.framework.base import HookRef - - class TestResourcePlugin(Plugin): - async def resource_post_fetch(self, payload, context): - return ResourcePostFetchResult( - continue_processing=True, - modified_payload=None, - ) - - config = PluginConfig( - name="test_plugin", - description="Test resource plugin", - author="test", - kind="test.Plugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_POST_FETCH], - tags=["test"], - mode=PluginMode.CONCURRENT, - ) - plugin = TestResourcePlugin(config) - plugin_ref = PluginRef(plugin) - hook_ref = HookRef(ResourceHookType.RESOURCE_POST_FETCH, plugin_ref) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - with patch.object(manager._registry, "get_hook_refs_for_hook", return_value=[hook_ref]): - content = ResourceContent(type="resource", id="123", uri="test://resource", text="Test") - payload = ResourcePostFetchPayload(uri="test://resource", content=content) - global_context = GlobalContext(request_id="test-123") - - result, contexts = await manager.invoke_hook( - ResourceHookType.RESOURCE_POST_FETCH, payload, global_context, {} - ) - - assert result.continue_processing is True - manager._registry.get_hook_refs_for_hook.assert_called_with(hook_type=ResourceHookType.RESOURCE_POST_FETCH) - - await manager.shutdown() - - @pytest.mark.asyncio - async def test_resource_hook_chain_execution(self): - """Test multiple resource plugins executing in priority order.""" - - class FirstPlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - # Add metadata - payload.metadata["first"] = True - return ResourcePreFetchResult( - continue_processing=True, - modified_payload=payload, - ) - - class SecondPlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - # Check first plugin ran - assert payload.metadata.get("first") is True - payload.metadata["second"] = True - return ResourcePreFetchResult( - continue_processing=True, - modified_payload=payload, - ) - - config1 = PluginConfig( - name="first", - description="First plugin", - author="test", - kind="test.First", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["test"], - priority=10, # Higher priority - ) - config2 = PluginConfig( - name="second", - description="Second plugin", - author="test", - kind="test.Second", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["test"], - priority=20, # Lower priority - ) - - plugin1 = FirstPlugin(config1) - plugin2 = SecondPlugin(config2) - - # Create refs - ref1 = PluginRef(plugin1) - ref2 = PluginRef(plugin2) - - # Verify priority ordering - assert ref1.priority < ref2.priority # Lower number = higher priority - - @pytest.mark.asyncio - async def test_resource_hook_error_handling(self): - """Test resource hook error handling.""" - # First-Party - from cpex.framework.base import HookRef - - class ErrorPlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - raise ValueError("Test error in plugin") - - config = PluginConfig( - name="error_plugin", - description="Error plugin", - author="test", - kind="test.ErrorPlugin", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["test"], - mode=PluginMode.AUDIT, - on_error=OnError.IGNORE, # Continue on error - ) - plugin = ErrorPlugin(config) - plugin_ref = PluginRef(plugin) - hook_ref = HookRef(ResourceHookType.RESOURCE_PRE_FETCH, plugin_ref) - - manager = PluginManager("./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml") - await manager.initialize() - - payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) - global_context = GlobalContext(request_id="test-123") - - # Test with audit mode - should handle error gracefully - with patch.object(manager._registry, "get_hook_refs_for_hook", return_value=[hook_ref]): - result, contexts = await manager.invoke_hook(ResourceHookType.RESOURCE_PRE_FETCH, payload, global_context) - assert result.continue_processing is True # Continues despite error - - # Test with concurrent mode + on_error=FAIL (default) - should raise PluginError - fail_config = config.model_copy(update={"mode": PluginMode.CONCURRENT, "on_error": OnError.FAIL}) - fail_plugin = ErrorPlugin(fail_config) - fail_ref = HookRef(ResourceHookType.RESOURCE_PRE_FETCH, PluginRef(fail_plugin)) - with patch.object(manager._registry, "get_hook_refs_for_hook", return_value=[fail_ref]): - with pytest.raises(PluginError): - result, contexts = await manager.invoke_hook( - ResourceHookType.RESOURCE_PRE_FETCH, payload, global_context - ) - - await manager.shutdown() - - @pytest.mark.asyncio - async def test_resource_uri_modification(self): - """Test resource URI modification in pre-fetch.""" - - class URIModifierPlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - # Modify URI to add prefix - modified_payload = ResourcePreFetchPayload( - uri=f"cached://{payload.uri}", - metadata=payload.metadata, - ) - return ResourcePreFetchResult( - continue_processing=True, - modified_payload=modified_payload, - ) - - config = PluginConfig( - name="uri_modifier", - description="URI modifier plugin", - author="test", - kind="test.URIModifier", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["modifier"], - ) - plugin = URIModifierPlugin(config) - payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) - context = PluginContext(global_context=GlobalContext(request_id="test-123")) - - result = await plugin.resource_pre_fetch(payload, context) - - assert result.continue_processing is True - assert result.modified_payload is not None - assert result.modified_payload.uri == "cached://test://resource" - - @pytest.mark.asyncio - async def test_resource_metadata_enrichment(self): - """Test resource metadata enrichment in pre-fetch.""" - - class MetadataEnricherPlugin(Plugin): - async def resource_pre_fetch(self, payload, context): - # Add metadata - payload.metadata["timestamp"] = "2024-01-01T00:00:00Z" - payload.metadata["user"] = context.global_context.user - payload.metadata["request_id"] = context.global_context.request_id - return ResourcePreFetchResult( - continue_processing=True, - modified_payload=payload, - ) - - config = PluginConfig( - name="metadata_enricher", - description="Metadata enricher plugin", - author="test", - kind="test.Enricher", - version="1.0.0", - hooks=[ResourceHookType.RESOURCE_PRE_FETCH], - tags=["enricher"], - ) - plugin = MetadataEnricherPlugin(config) - payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) - context = PluginContext(global_context=GlobalContext(request_id="test-123", user="testuser")) - - result = await plugin.resource_pre_fetch(payload, context) - - assert result.continue_processing is True - assert result.modified_payload is not None - assert result.modified_payload.metadata["timestamp"] == "2024-01-01T00:00:00Z" - assert result.modified_payload.metadata["user"] == "testuser" - assert result.modified_payload.metadata["request_id"] == "test-123" diff --git a/tests/unit/cpex/framework/test_settings.py b/tests/unit/cpex/framework/test_settings.py deleted file mode 100644 index 8b9e207a..00000000 --- a/tests/unit/cpex/framework/test_settings.py +++ /dev/null @@ -1,491 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_settings.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests for the plugin framework settings module. -Verifies default values, environment variable overrides, and settings isolation. -""" - -# Standard -import os - -# Third-Party -import pytest - -# First-Party -from cpex.framework.settings import PluginsSettings - - -class TestPluginsSettingsDefaults: - """Test that PluginsSettings has correct defaults.""" - - @pytest.fixture(autouse=True) - def _clean_plugins_env(self, monkeypatch): - """Remove PLUGINS_ env vars and .env file so tests verify true defaults.""" - for key in list(os.environ): - if key.startswith("PLUGINS_") or key == "UNIX_SOCKET_PATH": - monkeypatch.delenv(key, raising=False) - # Prevent values from .env file leaking into tests - monkeypatch.setattr(PluginsSettings, "model_config", {**PluginsSettings.model_config, "env_file": None}) - - def test_default_enabled(self): - s = PluginsSettings() - assert s.enabled is False - - def test_default_config_file(self): - s = PluginsSettings() - assert s.config_file == "plugins/config.yaml" - - def test_default_plugin_timeout(self): - s = PluginsSettings() - assert s.plugin_timeout == 30 - - def test_default_log_level(self): - s = PluginsSettings() - assert s.log_level == "INFO" - - def test_default_skip_ssl_verify(self): - s = PluginsSettings() - assert s.skip_ssl_verify is False - - def test_default_httpx_max_connections(self): - s = PluginsSettings() - assert s.httpx_max_connections == 200 - - def test_default_httpx_max_keepalive_connections(self): - s = PluginsSettings() - assert s.httpx_max_keepalive_connections == 100 - - def test_default_httpx_keepalive_expiry(self): - s = PluginsSettings() - assert s.httpx_keepalive_expiry == 30.0 - - def test_default_httpx_connect_timeout(self): - s = PluginsSettings() - assert s.httpx_connect_timeout == 5.0 - - def test_default_httpx_read_timeout(self): - s = PluginsSettings() - assert s.httpx_read_timeout == 120.0 - - def test_default_httpx_write_timeout(self): - s = PluginsSettings() - assert s.httpx_write_timeout == 30.0 - - def test_default_httpx_pool_timeout(self): - s = PluginsSettings() - assert s.httpx_pool_timeout == 10.0 - - def test_default_cli_completion(self): - s = PluginsSettings() - assert s.cli_completion is False - - def test_default_cli_markup_mode(self): - s = PluginsSettings() - assert s.cli_markup_mode is None - - # --- New transport/TLS/runtime fields default to None --- - - def test_default_client_mtls_fields(self): - s = PluginsSettings() - assert s.client_mtls_certfile is None - assert s.client_mtls_keyfile is None - assert s.client_mtls_ca_bundle is None - assert s.client_mtls_keyfile_password is None - assert s.client_mtls_verify is None - assert s.client_mtls_check_hostname is None - - def test_default_server_ssl_fields(self): - s = PluginsSettings() - assert s.server_ssl_keyfile is None - assert s.server_ssl_certfile is None - assert s.server_ssl_ca_certs is None - assert s.server_ssl_keyfile_password is None - assert s.server_ssl_cert_reqs is None - - def test_default_server_fields(self): - s = PluginsSettings() - assert s.server_host is None - assert s.server_port is None - assert s.server_uds is None - assert s.server_ssl_enabled is None - - def test_default_grpc_client_mtls_fields(self): - s = PluginsSettings() - assert s.grpc_client_mtls_certfile is None - assert s.grpc_client_mtls_keyfile is None - assert s.grpc_client_mtls_ca_bundle is None - assert s.grpc_client_mtls_keyfile_password is None - assert s.grpc_client_mtls_verify is None - - def test_default_grpc_server_ssl_fields(self): - s = PluginsSettings() - assert s.grpc_server_ssl_keyfile is None - assert s.grpc_server_ssl_certfile is None - assert s.grpc_server_ssl_ca_certs is None - assert s.grpc_server_ssl_keyfile_password is None - assert s.grpc_server_ssl_client_auth is None - - def test_default_grpc_server_fields(self): - s = PluginsSettings() - assert s.grpc_server_host is None - assert s.grpc_server_port is None - assert s.grpc_server_uds is None - assert s.grpc_server_ssl_enabled is None - - def test_default_unix_socket_path(self): - s = PluginsSettings() - assert s.unix_socket_path is None - - def test_default_runtime_fields(self): - s = PluginsSettings() - assert s.config_path is None - assert s.transport is None - - -class TestPluginsSettingsEnvOverrides: - """Test that PLUGINS_ prefixed env vars override defaults.""" - - def test_enabled_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_ENABLED", "true") - s = PluginsSettings() - assert s.enabled is True - - def test_plugin_timeout_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_PLUGIN_TIMEOUT", "60") - s = PluginsSettings() - assert s.plugin_timeout == 60 - - def test_skip_ssl_verify_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SKIP_SSL_VERIFY", "true") - s = PluginsSettings() - assert s.skip_ssl_verify is True - - def test_httpx_connect_timeout_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_HTTPX_CONNECT_TIMEOUT", "15.0") - s = PluginsSettings() - assert s.httpx_connect_timeout == 15.0 - - def test_httpx_read_timeout_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_HTTPX_READ_TIMEOUT", "300.0") - s = PluginsSettings() - assert s.httpx_read_timeout == 300.0 - - def test_config_file_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_CONFIG_FILE", "/custom/path.yaml") - s = PluginsSettings() - assert s.config_file == "/custom/path.yaml" - - def test_cli_markup_mode_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_CLI_MARKUP_MODE", "markdown") - s = PluginsSettings() - assert s.cli_markup_mode == "markdown" - - # --- New transport/TLS/runtime field overrides --- - - def test_client_mtls_verify_bool_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_VERIFY", "true") - s = PluginsSettings() - assert s.client_mtls_verify is True - - def test_server_port_int_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_PORT", "9000") - s = PluginsSettings() - assert s.server_port == 9000 - - def test_server_host_str_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_HOST", "0.0.0.0") - s = PluginsSettings() - assert s.server_host == "0.0.0.0" - - def test_grpc_server_ssl_enabled_bool_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_ENABLED", "false") - s = PluginsSettings() - assert s.grpc_server_ssl_enabled is False - - def test_unix_socket_path_alias_override(self, monkeypatch): - """UNIX_SOCKET_PATH (no PLUGINS_ prefix) is accepted via AliasChoices.""" - monkeypatch.setenv("UNIX_SOCKET_PATH", "/tmp/test.sock") - s = PluginsSettings() - assert s.unix_socket_path == "/tmp/test.sock" - - def test_unix_socket_path_prefixed_override(self, monkeypatch): - """PLUGINS_UNIX_SOCKET_PATH is also accepted.""" - monkeypatch.setenv("PLUGINS_UNIX_SOCKET_PATH", "/tmp/prefixed.sock") - s = PluginsSettings() - assert s.unix_socket_path == "/tmp/prefixed.sock" - - def test_config_path_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_CONFIG_PATH", "/custom/config.yaml") - s = PluginsSettings() - assert s.config_path == "/custom/config.yaml" - - def test_transport_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") - s = PluginsSettings() - assert s.transport == "stdio" - - def test_server_ssl_cert_reqs_int_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_SSL_CERT_REQS", "1") - s = PluginsSettings() - assert s.server_ssl_cert_reqs == 1 - - def test_grpc_server_port_int_override(self, monkeypatch): - monkeypatch.setenv("PLUGINS_GRPC_SERVER_PORT", "50052") - s = PluginsSettings() - assert s.grpc_server_port == 50052 - - def test_empty_optional_values_are_treated_as_none(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SERVER_SSL_CERT_REQS", "") - monkeypatch.setenv("PLUGINS_SERVER_PORT", "") - monkeypatch.setenv("PLUGINS_SERVER_SSL_ENABLED", "") - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_VERIFY", "") - monkeypatch.setenv("PLUGINS_CLIENT_MTLS_CHECK_HOSTNAME", "") - monkeypatch.setenv("PLUGINS_GRPC_CLIENT_MTLS_VERIFY", "") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_PORT", "") - monkeypatch.setenv("PLUGINS_GRPC_SERVER_SSL_ENABLED", "") - s = PluginsSettings() - assert s.server_ssl_cert_reqs is None - assert s.server_port is None - assert s.server_ssl_enabled is None - assert s.client_mtls_verify is None - assert s.client_mtls_check_hostname is None - assert s.grpc_client_mtls_verify is None - assert s.grpc_server_port is None - assert s.grpc_server_ssl_enabled is None - - -class TestPluginsSettingsModuleSingleton: - """Test the module-level settings singleton.""" - - def test_module_settings_instance_exists(self): - from cpex.framework.settings import settings - - assert settings - - -class TestPluginsSettingsStartupIsolation: - """Startup fields must not fail due to unrelated malformed plugin env vars.""" - - def test_config_file_survives_malformed_server_port(self, monkeypatch): - """config_file must be readable even with PLUGINS_SERVER_PORT=abc.""" - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - settings.cache_clear() - try: - assert settings.config_file == "plugins/config.yaml" - finally: - settings.cache_clear() - - def test_plugin_timeout_survives_malformed_server_port(self, monkeypatch): - """plugin_timeout must be readable even with PLUGINS_SERVER_PORT=abc.""" - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - settings.cache_clear() - try: - assert settings.plugin_timeout == 30 - finally: - settings.cache_clear() - - def test_config_file_env_override(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_CONFIG_FILE", "/custom/plugins.yaml") - settings.cache_clear() - try: - assert settings.config_file == "/custom/plugins.yaml" - finally: - settings.cache_clear() - - def test_plugin_timeout_env_override(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_PLUGIN_TIMEOUT", "60") - settings.cache_clear() - try: - assert settings.plugin_timeout == 60 - finally: - settings.cache_clear() - - -class TestPluginsSettingsEnabledFlag: - """Test lazy enabled flag resolution behavior.""" - - def test_enabled_reads_from_env_file_without_parsing_full_settings(self, monkeypatch, tmp_path): - from cpex.framework.settings import settings - - env_file = tmp_path / ".env" - env_file.write_text("PLUGINS_ENABLED=true\nPLUGINS_SERVER_PORT=abc\n", encoding="utf-8") - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("PLUGINS_ENABLED", raising=False) - settings.cache_clear() - - assert settings.enabled is True - - def test_enabled_reads_inline_comment_value(self, monkeypatch, tmp_path): - from cpex.framework.settings import settings - - env_file = tmp_path / ".env" - env_file.write_text("PLUGINS_ENABLED=true # enable plugin framework\n", encoding="utf-8") - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("PLUGINS_ENABLED", raising=False) - settings.cache_clear() - - assert settings.enabled is True - - -class TestPluginsHttpClientSettings: - """Test the lightweight PluginsHttpClientSettings model.""" - - def test_defaults(self): - from cpex.framework.settings import PluginsHttpClientSettings - - s = PluginsHttpClientSettings() - assert s.skip_ssl_verify is False - assert s.httpx_max_connections == 200 - assert s.httpx_max_keepalive_connections == 100 - assert s.httpx_keepalive_expiry == 30.0 - assert s.httpx_connect_timeout == 5.0 - assert s.httpx_read_timeout == 120.0 - assert s.httpx_write_timeout == 30.0 - assert s.httpx_pool_timeout == 10.0 - - def test_env_override(self, monkeypatch): - from cpex.framework.settings import PluginsHttpClientSettings - - monkeypatch.setenv("PLUGINS_HTTPX_CONNECT_TIMEOUT", "15.0") - monkeypatch.setenv("PLUGINS_SKIP_SSL_VERIFY", "true") - s = PluginsHttpClientSettings() - assert s.httpx_connect_timeout == 15.0 - assert s.skip_ssl_verify is True - - def test_get_http_client_settings_cached(self): - from cpex.framework.settings import get_http_client_settings - - s1 = get_http_client_settings() - s2 = get_http_client_settings() - assert s1 is s2 - - -class TestPluginsCliSettings: - """Test the lightweight PluginsCliSettings model.""" - - @pytest.fixture(autouse=True) - def _clean_cli_env(self, monkeypatch): - """Remove PLUGINS_CLI_ env vars and .env file so tests verify true defaults.""" - for key in list(os.environ): - if key.startswith("PLUGINS_CLI_"): - monkeypatch.delenv(key, raising=False) - from cpex.framework.settings import PluginsCliSettings - - monkeypatch.setattr(PluginsCliSettings, "model_config", {**PluginsCliSettings.model_config, "env_file": None}) - - def test_defaults(self): - from cpex.framework.settings import PluginsCliSettings - - s = PluginsCliSettings() - assert s.cli_completion is False - assert s.cli_markup_mode is None - - def test_env_override(self, monkeypatch): - from cpex.framework.settings import PluginsCliSettings - - monkeypatch.setenv("PLUGINS_CLI_COMPLETION", "true") - monkeypatch.setenv("PLUGINS_CLI_MARKUP_MODE", "markdown") - s = PluginsCliSettings() - assert s.cli_completion is True - assert s.cli_markup_mode == "markdown" - - def test_get_cli_settings_cached(self): - from cpex.framework.settings import get_cli_settings - - s1 = get_cli_settings() - s2 = get_cli_settings() - assert s1 is s2 - - -class TestLazySettingsWrapperProperties: - """Test LazySettingsWrapper @property methods bypass __getattr__.""" - - @pytest.fixture(autouse=True) - def _clean_env(self, monkeypatch): - for key in list(os.environ): - if key.startswith("PLUGINS_") or key == "UNIX_SOCKET_PATH": - monkeypatch.delenv(key, raising=False) - - def test_ssrf_protection_enabled_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "false") - settings.cache_clear() - assert settings.ssrf_protection_enabled is False - settings.cache_clear() - - def test_transport_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") - settings.cache_clear() - assert settings.transport == "stdio" - settings.cache_clear() - - def test_unix_socket_path_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_UNIX_SOCKET_PATH", "/tmp/test.sock") - settings.cache_clear() - assert settings.unix_socket_path == "/tmp/test.sock" - settings.cache_clear() - - def test_default_hook_policy_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_DEFAULT_HOOK_POLICY", "deny") - settings.cache_clear() - assert settings.default_hook_policy == "deny" - settings.cache_clear() - - def test_config_path_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_CONFIG_PATH", "/custom/path.yaml") - settings.cache_clear() - assert settings.config_path == "/custom/path.yaml" - settings.cache_clear() - - def test_cli_completion_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_CLI_COMPLETION", "true") - settings.cache_clear() - assert settings.cli_completion is True - settings.cache_clear() - - def test_cli_markup_mode_property(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_CLI_MARKUP_MODE", "markdown") - settings.cache_clear() - assert settings.cli_markup_mode == "markdown" - settings.cache_clear() - - def test_cli_properties_survive_unrelated_malformed_env(self, monkeypatch): - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_CLI_MARKUP_MODE", "rich") - settings.cache_clear() - assert settings.cli_markup_mode == "rich" - settings.cache_clear() - - def test_getattr_fallback_to_full_settings(self): - """Accessing a field without a @property falls back to __getattr__.""" - from cpex.framework.settings import settings - - settings.cache_clear() - assert settings.plugin_timeout == 30 - settings.cache_clear() diff --git a/tests/unit/cpex/framework/test_tenant_plugin_manager.py b/tests/unit/cpex/framework/test_tenant_plugin_manager.py deleted file mode 100644 index 0eec689d..00000000 --- a/tests/unit/cpex/framework/test_tenant_plugin_manager.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_tenant_plugin_manager.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Tests for TenantPluginManager. -""" - -# Third-Party -import pytest - -# First-Party -from cpex.framework.loader.config import ConfigLoader -from cpex.framework.manager import TenantPluginManager - -FIXTURE_NO_PLUGIN = "./tests/unit/cpex/fixtures/configs/valid_no_plugin.yaml" - - -@pytest.mark.asyncio -async def test_tenant_plugin_manager_with_config_object(): - """Test TenantPluginManager initialization with Config object.""" - config = ConfigLoader.load_config(FIXTURE_NO_PLUGIN) - manager = TenantPluginManager(config=config) - try: - await manager.initialize() - assert manager.initialized - assert manager._config_path is None - assert manager._config is config - finally: - await manager.shutdown() - - -@pytest.mark.asyncio -async def test_tenant_plugin_manager_with_string_path(): - """Test TenantPluginManager initialization with string path.""" - manager = TenantPluginManager(config=FIXTURE_NO_PLUGIN) - try: - await manager.initialize() - assert manager.initialized - assert manager._config_path == FIXTURE_NO_PLUGIN - assert manager._config is not None - finally: - await manager.shutdown() diff --git a/tests/unit/cpex/framework/test_utils.py b/tests/unit/cpex/framework/test_utils.py deleted file mode 100644 index 06e267b5..00000000 --- a/tests/unit/cpex/framework/test_utils.py +++ /dev/null @@ -1,529 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_utils.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 -Authors: Teryl Taylor - -Unit tests for utilities. -""" - -# Standard - -# First-Party -from cpex.framework import ( - GlobalContext, - PluginCondition, - PromptPosthookPayload, - PromptPrehookPayload, - ToolPostInvokePayload, - ToolPreInvokePayload, -) -from cpex.framework.utils import import_module, matches, parse_class_name, payload_matches - - -def test_server_ids(): - """Test conditional matching with server IDs, tenant IDs, and user patterns.""" - condition1 = PluginCondition(server_ids={"1", "2"}) - context1 = GlobalContext(server_id="1", tenant_id="4", request_id="5") - - payload1 = PromptPrehookPayload(prompt_id="test_prompt", args={}) - - assert matches(condition=condition1, context=context1) - assert payload_matches(payload1, "prompt_pre_fetch", [condition1], context1) - - context2 = GlobalContext(server_id="3", tenant_id="6", request_id="1") - assert not matches(condition=condition1, context=context2) - assert not payload_matches(payload1, "prompt_pre_fetch", [condition1], context2) - - condition2 = PluginCondition(server_ids={"1"}, tenant_ids={"4"}) - - context2 = GlobalContext(server_id="1", tenant_id="4", request_id="1") - - assert matches(condition2, context2) - assert payload_matches(payload1, "prompt_pre_fetch", [condition2], context2) - - context3 = GlobalContext(server_id="1", tenant_id="5", request_id="1") - - assert not matches(condition2, context3) - assert not payload_matches(payload1, "prompt_pre_fetch", [condition2], context3) - - condition4 = PluginCondition(user_patterns=["blah", "barker", "bobby"]) - context4 = GlobalContext(user="blah", request_id="1") - - assert matches(condition4, context4) - assert payload_matches(payload1, "prompt_pre_fetch", [condition4], context4) - - context5 = GlobalContext(user="barney", request_id="1") - assert not matches(condition4, context5) - assert not payload_matches(payload1, "prompt_pre_fetch", [condition4], context5) - - condition5 = PluginCondition(server_ids={"1", "2"}, prompts={"test_prompt"}) - - assert payload_matches(payload1, "prompt_pre_fetch", [condition5], context1) - condition6 = PluginCondition(server_ids={"1", "2"}, prompts={"test_prompt2"}) - assert not payload_matches(payload1, "prompt_pre_fetch", [condition6], context1) - - -# ============================================================================ -# Test import_module function -# ============================================================================ - - -def test_import_module(): - """Test the import_module function.""" - # Test importing an allowed module (json is not blocked) - import json - - imported_json = import_module("json") - assert imported_json is json - - # Test importing a plugin-namespace module - mod = import_module("cpex.framework.utils") - assert hasattr(mod, "import_module") - - # Test caching - calling again should return same object - imported_json2 = import_module("json") - assert imported_json2 is imported_json - - # Test that blocked modules are rejected - import pytest - - for blocked in ("os", "sys", "subprocess", "shutil", "os.path"): - with pytest.raises(ImportError, match="blocked for security"): - import_module(blocked) - - # Test that path-traversal names are rejected - with pytest.raises(ImportError, match="invalid characters"): - import_module("some..module") - - -# ============================================================================ -# Test parse_class_name function -# ============================================================================ - - -def test_parse_class_name(): - """Test the parse_class_name function with various inputs.""" - # Test fully qualified class name - module, class_name = parse_class_name("module.submodule.ClassName") - assert module == "module.submodule" - assert class_name == "ClassName" - - # Test simple class name (no module) - module, class_name = parse_class_name("SimpleClass") - assert module == "" - assert class_name == "SimpleClass" - - # Test package.Class format - module, class_name = parse_class_name("package.Class") - assert module == "package" - assert class_name == "Class" - - # Test deeply nested class name - module, class_name = parse_class_name("a.b.c.d.e.MyClass") - assert module == "a.b.c.d.e" - assert class_name == "MyClass" - - -# ============================================================================ -# Test payload_matches for prompt hooks -# ============================================================================ - - -def test_payload_matches_prompt_post_fetch(): - """Test payload_matches for prompt_post_fetch hook.""" - # Test basic matching - payload = PromptPosthookPayload(prompt_id="greeting", result={"messages": []}) - condition = PluginCondition(prompts={"greeting"}) - context = GlobalContext(request_id="req1") - - assert payload_matches(payload, "prompt_post_fetch", [condition], context) is True - - # Test no match - payload2 = PromptPosthookPayload(prompt_id="other", result={"messages": []}) - assert payload_matches(payload2, "prompt_post_fetch", [condition], context) is False - - # Test with server_id condition - condition_with_server = PluginCondition(server_ids={"srv1"}, prompts={"greeting"}) - context_with_server = GlobalContext(request_id="req1", server_id="srv1") - - assert payload_matches(payload, "prompt_post_fetch", [condition_with_server], context_with_server) is True - - # Test with mismatched server_id - context_wrong_server = GlobalContext(request_id="req1", server_id="srv2") - assert payload_matches(payload, "prompt_post_fetch", [condition_with_server], context_wrong_server) is False - - -def test_payload_matches_prompt_multiple_conditions(): - """Test payload_matches for prompts with multiple conditions (OR logic).""" - # Create the payload - payload = PromptPosthookPayload(prompt_id="greeting", result={"messages": []}) - - # First condition fails, second condition succeeds - condition1 = PluginCondition(server_ids={"srv1"}, prompts={"greeting"}) - condition2 = PluginCondition(server_ids={"srv2"}, prompts={"greeting"}) - context = GlobalContext(request_id="req1", server_id="srv2") - - assert payload_matches(payload, "prompt_post_fetch", [condition1, condition2], context) is True - - # Both conditions fail - context_no_match = GlobalContext(request_id="req1", server_id="srv3") - assert payload_matches(payload, "prompt_post_fetch", [condition1, condition2], context_no_match) is False - - # Test reset logic between conditions - condition3 = PluginCondition(server_ids={"srv3"}, prompts={"other"}) - condition4 = PluginCondition(prompts={"greeting"}) - assert payload_matches(payload, "prompt_post_fetch", [condition3, condition4], context_no_match) is True - - -# ============================================================================ -# Test payload_matches for tool hooks -# ============================================================================ - - -def test_payload_matches_tool_pre_invoke(): - """Test payload_matches for tool_pre_invoke hook.""" - # Test basic matching - payload = ToolPreInvokePayload(name="calculator", args={"operation": "add"}) - condition = PluginCondition(tools={"calculator"}) - context = GlobalContext(request_id="req1") - - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - # Test no match - payload2 = ToolPreInvokePayload(name="other_tool", args={}) - assert payload_matches(payload2, "tool_pre_invoke", [condition], context) is False - - # Test with server_id condition - condition_with_server = PluginCondition(server_ids={"srv1"}, tools={"calculator"}) - context_with_server = GlobalContext(request_id="req1", server_id="srv1") - - assert payload_matches(payload, "tool_pre_invoke", [condition_with_server], context_with_server) is True - - # Test with mismatched server_id - context_wrong_server = GlobalContext(request_id="req1", server_id="srv2") - assert payload_matches(payload, "tool_pre_invoke", [condition_with_server], context_wrong_server) is False - - -def test_payload_matches_tool_pre_invoke_multiple_conditions(): - """Test payload_matches for tool_pre_invoke with multiple conditions (OR logic).""" - payload = ToolPreInvokePayload(name="calculator", args={"operation": "add"}) - - # First condition fails, second condition succeeds - condition1 = PluginCondition(server_ids={"srv1"}, tools={"calculator"}) - condition2 = PluginCondition(server_ids={"srv2"}, tools={"calculator"}) - context = GlobalContext(request_id="req1", server_id="srv2") - - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context) is True - - # Both conditions fail - context_no_match = GlobalContext(request_id="req1", server_id="srv3") - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context_no_match) is False - - # Test reset logic between conditions - condition3 = PluginCondition(server_ids={"srv3"}, tools={"other"}) - condition4 = PluginCondition(tools={"calculator"}) - assert payload_matches(payload, "tool_pre_invoke", [condition3, condition4], context_no_match) is True - - -# ============================================================================ -# Test payload_matches for tool_post_invoke -# ============================================================================ - - -def test_payload_matches_tool_post_invoke(): - """Test payload_matches for tool_post_invoke hook.""" - # Test basic matching - payload = ToolPostInvokePayload(name="calculator", result={"value": 42}) - condition = PluginCondition(tools={"calculator"}) - context = GlobalContext(request_id="req1") - - assert payload_matches(payload, "tool_post_invoke", [condition], context) is True - - # Test no match - payload2 = ToolPostInvokePayload(name="other_tool", result={}) - assert payload_matches(payload2, "tool_post_invoke", [condition], context) is False - - # Test with server_id condition - condition_with_server = PluginCondition(server_ids={"srv1"}, tools={"calculator"}) - context_with_server = GlobalContext(request_id="req1", server_id="srv1") - - assert payload_matches(payload, "tool_post_invoke", [condition_with_server], context_with_server) is True - - # Test with mismatched server_id - context_wrong_server = GlobalContext(request_id="req1", server_id="srv2") - assert payload_matches(payload, "tool_post_invoke", [condition_with_server], context_wrong_server) is False - - -def test_payload_matches_tool_post_invoke_multiple_conditions(): - """Test payload_matches for tool_post_invoke with multiple conditions (OR logic).""" - payload = ToolPostInvokePayload(name="calculator", result={"value": 42}) - - # First condition fails, second condition succeeds - condition1 = PluginCondition(server_ids={"srv1"}, tools={"calculator"}) - condition2 = PluginCondition(server_ids={"srv2"}, tools={"calculator"}) - context = GlobalContext(request_id="req1", server_id="srv2") - - assert payload_matches(payload, "tool_post_invoke", [condition1, condition2], context) is True - - # Both conditions fail - context_no_match = GlobalContext(request_id="req1", server_id="srv3") - assert payload_matches(payload, "tool_post_invoke", [condition1, condition2], context_no_match) is False - - # Test reset logic between conditions - condition3 = PluginCondition(server_ids={"srv3"}, tools={"other"}) - condition4 = PluginCondition(tools={"calculator"}) - assert payload_matches(payload, "tool_post_invoke", [condition3, condition4], context_no_match) is True - - -# ============================================================================ -# Test payload_matches for prompt_pre_fetch with multiple conditions -# ============================================================================ - - -def test_payload_matches_prompt_pre_fetch_multiple_conditions(): - """Test payload_matches for prompt_pre_fetch with multiple conditions to cover OR logic paths.""" - payload = PromptPrehookPayload(prompt_id="greeting", args={}) - - # First condition fails, second condition succeeds - condition1 = PluginCondition(server_ids={"srv1"}, prompts={"greeting"}) - condition2 = PluginCondition(server_ids={"srv2"}, prompts={"greeting"}) - context = GlobalContext(request_id="req1", server_id="srv2") - - assert payload_matches(payload, "prompt_pre_fetch", [condition1, condition2], context) is True - - # Both conditions fail - context_no_match = GlobalContext(request_id="req1", server_id="srv3") - assert payload_matches(payload, "prompt_pre_fetch", [condition1, condition2], context_no_match) is False - - # Test reset logic between conditions (OR logic) - condition3 = PluginCondition(server_ids={"srv3"}, prompts={"other"}) - condition4 = PluginCondition(prompts={"greeting"}) - assert payload_matches(payload, "prompt_pre_fetch", [condition3, condition4], context_no_match) is True - - -# ============================================================================ -# Test matches function edge cases -# ============================================================================ - - -# ============================================================================ -# Test StructuredData and coerce_nested -# ============================================================================ - - -def test_structured_data_attribute_access(): - """Test StructuredData provides attribute access on extra fields.""" - from cpex.framework.utils import StructuredData - - sd = StructuredData(name="test", value=42) - assert sd.name == "test" - assert sd.value == 42 - - -def test_structured_data_model_dump(): - """Test StructuredData round-trips through model_dump.""" - from cpex.framework.utils import StructuredData - - sd = StructuredData(role="user", content="hello") - dumped = sd.model_dump() - assert dumped == {"role": "user", "content": "hello"} - - -def test_coerce_nested_dict(): - """Test coerce_nested converts a dict to StructuredData.""" - from cpex.framework.utils import StructuredData, coerce_nested - - result = coerce_nested({"name": "test"}) - assert isinstance(result, StructuredData) - assert result.name == "test" - - -def test_coerce_nested_deeply_nested(): - """Test coerce_nested handles deeply nested dicts.""" - from cpex.framework.utils import coerce_nested - - data = { - "messages": [ - {"role": "user", "content": {"type": "text", "text": "hi"}}, - ], - } - result = coerce_nested(data) - assert result.messages[0].content.text == "hi" - assert result.messages[0].role == "user" - - -def test_coerce_nested_list(): - """Test coerce_nested handles lists.""" - from cpex.framework.utils import StructuredData, coerce_nested - - result = coerce_nested([{"a": 1}, {"b": 2}]) - assert isinstance(result, list) - assert len(result) == 2 - assert isinstance(result[0], StructuredData) - assert result[0].a == 1 - - -def test_coerce_nested_scalar(): - """Test coerce_nested passes through scalars unchanged.""" - from cpex.framework.utils import coerce_nested - - assert coerce_nested(42) == 42 - assert coerce_nested("hello") == "hello" - assert coerce_nested(None) is None - - -def test_coerce_nested_pydantic_model(): - """Test coerce_nested returns Pydantic models as-is.""" - from pydantic import BaseModel - - from cpex.framework.utils import coerce_nested - - class MyModel(BaseModel): - x: int = 1 - - model = MyModel() - assert coerce_nested(model) is model - - -# ============================================================================ -# Test ORJSONResponse -# ============================================================================ - - -def test_orjson_response_media_type(): - """Test ORJSONResponse has correct media type.""" - from cpex.framework.utils import ORJSONResponse - - assert ORJSONResponse.media_type == "application/json" - - -def test_orjson_response_render(): - """Test ORJSONResponse renders JSON bytes.""" - from cpex.framework.utils import ORJSONResponse - - response = ORJSONResponse(content={"status": "ok", "count": 42}) - assert response.body is not None - import orjson - - parsed = orjson.loads(response.body) - assert parsed == {"status": "ok", "count": 42} - - -def test_coerce_nested_depth_limit(): - """Test coerce_nested stops recursing at _COERCE_MAX_DEPTH.""" - from cpex.framework.utils import _COERCE_MAX_DEPTH, StructuredData, coerce_nested - - # Build a dict nested deeper than the limit - deeply = {"leaf": True} - for _ in range(_COERCE_MAX_DEPTH + 5): - deeply = {"child": deeply} - - result = coerce_nested(deeply) - # Walk down to _COERCE_MAX_DEPTH — each level should be StructuredData - node = result - for _ in range(_COERCE_MAX_DEPTH): - assert isinstance(node, StructuredData) - node = node.child - - # Beyond the limit, the value is left as a plain dict - assert isinstance(node, dict) - - -def test_coerce_nested_dict_breadth_limit(): - """Dict exceeding _COERCE_MAX_BREADTH is returned as plain dict.""" - from cpex.framework.utils import _COERCE_MAX_BREADTH, coerce_nested - - big_dict = {f"key_{i}": i for i in range(_COERCE_MAX_BREADTH + 1)} - result = coerce_nested(big_dict) - assert isinstance(result, dict), "Oversized dict should be returned as plain dict" - assert len(result) == _COERCE_MAX_BREADTH + 1 - - -def test_coerce_nested_list_breadth_limit(): - """List exceeding _COERCE_MAX_BREADTH is returned as plain list.""" - from cpex.framework.utils import _COERCE_MAX_BREADTH, coerce_nested - - big_list = [{"v": i} for i in range(_COERCE_MAX_BREADTH + 1)] - result = coerce_nested(big_list) - assert isinstance(result, list), "Oversized list should be returned as plain list" - # Items should NOT be coerced to StructuredData - assert isinstance(result[0], dict), "Items in oversized list should remain plain dicts" - - -def test_coerce_messages_converts_dicts(): - """Test coerce_messages converts list of dicts to StructuredData.""" - from cpex.framework.utils import StructuredData, coerce_messages - - msgs = [{"role": "user", "content": {"type": "text", "text": "hi"}}] - result = coerce_messages(msgs) - assert isinstance(result, list) - assert isinstance(result[0], StructuredData) - assert result[0].role == "user" - assert result[0].content.text == "hi" - - -def test_coerce_messages_passes_non_list(): - """Test coerce_messages returns non-list values unchanged.""" - from cpex.framework.utils import coerce_messages - - assert coerce_messages("hello") == "hello" - assert coerce_messages(42) == 42 - assert coerce_messages(None) is None - - -def test_coerce_messages_preserves_non_dict_items(): - """Test coerce_messages skips non-dict items in the list.""" - from cpex.framework.utils import StructuredData, coerce_messages - - msgs = [{"role": "user"}, "plain_string", 42] - result = coerce_messages(msgs) - assert isinstance(result[0], StructuredData) - assert result[1] == "plain_string" - assert result[2] == 42 - - -def test_orjson_response_render_non_str_keys(): - """Test ORJSONResponse handles non-string keys.""" - from cpex.framework.utils import ORJSONResponse - - response = ORJSONResponse(content={1: "one", 2: "two"}) - import orjson - - parsed = orjson.loads(response.body) - assert parsed == {"1": "one", "2": "two"} - - -# ============================================================================ -# Test matches function edge cases -# ============================================================================ - - -def test_matches_edge_cases(): - """Test the matches function with edge cases.""" - context = GlobalContext(request_id="req1", server_id="srv1", tenant_id="tenant1", user="admin_user") - - # Test empty conditions (should match everything) - empty_condition = PluginCondition() - assert matches(empty_condition, context) is True - - # Test user pattern matching - condition_user = PluginCondition(user_patterns=["admin", "root"]) - assert matches(condition_user, context) is True - - # Test user pattern no match - condition_user_no_match = PluginCondition(user_patterns=["guest", "visitor"]) - assert matches(condition_user_no_match, context) is False - - # Test context without user - context_no_user = GlobalContext(request_id="req1", server_id="srv1") - condition_user_required = PluginCondition(user_patterns=["admin"]) - assert matches(condition_user_required, context_no_user) is False # Fail-closed: no user means condition fails - - # Test all conditions together - complex_condition = PluginCondition(server_ids={"srv1", "srv2"}, tenant_ids={"tenant1"}, user_patterns=["admin"]) - assert matches(complex_condition, context) is True - - # Test complex condition with one mismatch - context_wrong_tenant = GlobalContext(request_id="req1", server_id="srv1", tenant_id="tenant2", user="admin_user") - assert matches(complex_condition, context_wrong_tenant) is False diff --git a/tests/unit/cpex/framework/test_utils_and_logic.py b/tests/unit/cpex/framework/test_utils_and_logic.py deleted file mode 100644 index 30816c2e..00000000 --- a/tests/unit/cpex/framework/test_utils_and_logic.py +++ /dev/null @@ -1,453 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/mcpgateway/plugins/framework/test_utils_and_logic.py -Copyright 2025 -SPDX-License-Identifier: Apache-2.0 - -Unit tests for hybrid AND/OR condition evaluation logic. - -This test module validates the breaking change from OR-based to AND/OR-based -condition evaluation in the plugin framework (Issue #3930). -""" - -# Third-Party - -# First-Party -from cpex.framework import ( - GlobalContext, - PluginCondition, - PromptPrehookPayload, - ResourcePreFetchPayload, - ToolPostInvokePayload, - ToolPreInvokePayload, -) -from cpex.framework.utils import matches, payload_matches - -# ============================================================================ -# Test Single Condition Object - AND Logic Within Object -# ============================================================================ - - -def test_single_condition_all_fields_match(): - """Test that all fields in a single condition object must match (AND logic).""" - # Setup: condition with tenant_ids, tools, and server_ids - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}, server_ids={"prod-server"}) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare", server_id="prod-server") - - # All fields match → should execute - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - -def test_single_condition_one_field_mismatch(): - """Test that if one field doesn't match, the condition fails (AND logic).""" - # Setup: condition with tenant_ids, tools, and server_ids - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}, server_ids={"prod-server"}) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - - # Test 1: Wrong tenant (other fields match) - context_wrong_tenant = GlobalContext( - request_id="req1", - tenant_id="finance", # Wrong tenant - server_id="prod-server", - ) - assert payload_matches(payload, "tool_pre_invoke", [condition], context_wrong_tenant) is False - - # Test 2: Wrong server (other fields match) - context_wrong_server = GlobalContext( - request_id="req1", - tenant_id="healthcare", - server_id="dev-server", # Wrong server - ) - assert payload_matches(payload, "tool_pre_invoke", [condition], context_wrong_server) is False - - # Test 3: Wrong tool (other fields match) - payload_wrong_tool = ToolPreInvokePayload(name="other_tool", args={}) - context_correct = GlobalContext(request_id="req1", tenant_id="healthcare", server_id="prod-server") - assert payload_matches(payload_wrong_tool, "tool_pre_invoke", [condition], context_correct) is False - - -def test_single_condition_multiple_fields_all_match(): - """Test multiple fields in one condition - all must match.""" - condition = PluginCondition(tenant_ids={"healthcare", "finance"}, tools={"tool1", "tool2"}, user_patterns=["admin"]) - - payload = ToolPreInvokePayload(name="tool1", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare", user="admin_alice") - - # All fields match → should execute - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - -def test_single_condition_multiple_fields_one_mismatch(): - """Test multiple fields in one condition - one mismatch fails the condition.""" - condition = PluginCondition(tenant_ids={"healthcare", "finance"}, tools={"tool1", "tool2"}, user_patterns=["admin"]) - - payload = ToolPreInvokePayload(name="tool1", args={}) - context = GlobalContext( - request_id="req1", - tenant_id="healthcare", - user="regular_user", # User pattern doesn't match - ) - - # User pattern doesn't match → should NOT execute - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is False - - -# ============================================================================ -# Test Multiple Condition Objects - OR Logic Across Objects -# ============================================================================ - - -def test_multiple_conditions_first_matches(): - """Test that if the first condition object matches, plugin executes (OR logic).""" - condition1 = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - condition2 = PluginCondition(server_ids={"prod"}, user_patterns=["admin"]) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - # First condition fully matches → should execute - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context) is True - - -def test_multiple_conditions_second_matches(): - """Test that if the second condition object matches, plugin executes (OR logic).""" - condition1 = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - condition2 = PluginCondition(server_ids={"prod"}, user_patterns=["admin"]) - - payload = ToolPreInvokePayload(name="other_tool", args={}) - context = GlobalContext(request_id="req1", server_id="prod", user="admin_alice") - - # First condition fails, second condition fully matches → should execute - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context) is True - - -def test_multiple_conditions_none_match(): - """Test that if no condition objects match, plugin doesn't execute.""" - condition1 = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - condition2 = PluginCondition(server_ids={"prod"}, user_patterns=["admin"]) - - payload = ToolPreInvokePayload(name="other_tool", args={}) - context = GlobalContext( - request_id="req1", - tenant_id="finance", # Doesn't match condition1 - server_id="dev", # Doesn't match condition2 - ) - - # No condition objects match → should NOT execute - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context) is False - - -def test_multiple_conditions_partial_matches_fail(): - """Test that partial matches in condition objects don't trigger execution.""" - condition1 = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - condition2 = PluginCondition(server_ids={"prod"}, user_patterns=["admin"]) - - # Context matches tenant from condition1 and server from condition2 - # but neither condition object fully matches - payload = ToolPreInvokePayload(name="other_tool", args={}) - context = GlobalContext( - request_id="req1", - tenant_id="healthcare", # Matches condition1 tenant - server_id="prod", # Matches condition2 server - user=None, # Explicitly set user to None - condition2 requires user pattern - ) - - # Condition1: tenant matches but tool doesn't → fails - # Condition2: server matches but user is None (pattern required) → fails - # Neither condition object fully matches → should NOT execute - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context) is False - - -# ============================================================================ -# Test Edge Cases -# ============================================================================ - - -def test_empty_conditions_list_matches_all(): - """Test that empty conditions list matches all requests.""" - payload = ToolPreInvokePayload(name="any_tool", args={}) - context = GlobalContext(request_id="req1") - - # Empty conditions → should match all - assert payload_matches(payload, "tool_pre_invoke", [], context) is True - - -def test_condition_with_no_fields_matches_all(): - """Test that a condition object with no fields matches all requests.""" - condition = PluginCondition() # No fields set - - payload = ToolPreInvokePayload(name="any_tool", args={}) - context = GlobalContext(request_id="req1") - - # Condition with no fields → should match all - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - -def test_null_values_in_context(): - """Test handling of None/null values in context.""" - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - context = GlobalContext( - request_id="req1", - tenant_id=None, # Null tenant - server_id=None, - ) - - # Tenant is None, doesn't match condition → should NOT execute - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is False - - -def test_empty_sets_in_condition(): - """Test handling of empty sets in condition fields.""" - condition = PluginCondition( - tenant_ids=set(), # Empty set - tools={"patient_reader"}, - ) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - # Empty tenant_ids set is treated as "no constraint" → should match - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - -# ============================================================================ -# Test Breaking Change Validation -# ============================================================================ - - -def test_breaking_change_old_or_behavior_no_longer_works(): - """Test that old OR behavior (any field match) no longer works.""" - # Old behavior: This would have matched because tenant OR tool matched - # New behavior: This should NOT match because not all fields match - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - - # Only tenant matches, tool doesn't - payload = ToolPreInvokePayload(name="other_tool", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - # Old OR logic would have matched (tenant matches) - # New AND logic should NOT match (tool doesn't match) - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is False - - -def test_breaking_change_new_and_behavior_works(): - """Test that new AND behavior works correctly.""" - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - # New AND logic: both tenant AND tool must match - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - -def test_migration_pattern_separate_conditions_for_or(): - """Test migration pattern: separate condition objects for OR logic.""" - # Migration pattern: To get OR behavior, use separate condition objects - condition1 = PluginCondition(tenant_ids={"healthcare"}) - condition2 = PluginCondition(tools={"patient_reader"}) - - # Test 1: Only tenant matches - payload1 = ToolPreInvokePayload(name="other_tool", args={}) - context1 = GlobalContext(request_id="req1", tenant_id="healthcare") - assert payload_matches(payload1, "tool_pre_invoke", [condition1, condition2], context1) is True - - # Test 2: Only tool matches - payload2 = ToolPreInvokePayload(name="patient_reader", args={}) - context2 = GlobalContext(request_id="req1", tenant_id="finance") - assert payload_matches(payload2, "tool_pre_invoke", [condition1, condition2], context2) is True - - # Test 3: Both match - payload3 = ToolPreInvokePayload(name="patient_reader", args={}) - context3 = GlobalContext(request_id="req1", tenant_id="healthcare") - assert payload_matches(payload3, "tool_pre_invoke", [condition1, condition2], context3) is True - - -# ============================================================================ -# Test Hook Type Coverage -# ============================================================================ - - -def test_tool_pre_invoke_hook(): - """Test AND/OR logic for tool_pre_invoke hook.""" - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is True - - -def test_tool_post_invoke_hook(): - """Test AND/OR logic for tool_post_invoke hook.""" - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - - payload = ToolPostInvokePayload(name="patient_reader", result={"data": "test"}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - assert payload_matches(payload, "tool_post_invoke", [condition], context) is True - - -def test_prompt_pre_fetch_hook(): - """Test AND/OR logic for prompt_pre_fetch hook.""" - condition = PluginCondition(tenant_ids={"healthcare"}, prompts={"greeting"}) - - payload = PromptPrehookPayload(prompt_id="greeting", args={}) - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - assert payload_matches(payload, "prompt_pre_fetch", [condition], context) is True - - -def test_resource_pre_fetch_hook(): - """Test AND/OR logic for resource_pre_fetch hook.""" - condition = PluginCondition(tenant_ids={"healthcare"}, resources={"file:///data.txt"}) - - payload = ResourcePreFetchPayload(uri="file:///data.txt") - context = GlobalContext(request_id="req1", tenant_id="healthcare") - - assert payload_matches(payload, "resource_pre_fetch", [condition], context) is True - - -# ============================================================================ -# Test Complex Scenarios -# ============================================================================ - - -def test_complex_defense_in_depth_scenario(): - """Test complex defense-in-depth security scenario with multiple condition objects.""" - # Scenario: PII filter should execute for: - # (healthcare tenant AND patient_reader tool) OR (prod server AND admin user) - condition1 = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}) - condition2 = PluginCondition(server_ids={"prod"}, user_patterns=["admin"]) - - # Test 1: Healthcare + patient_reader → should execute - payload1 = ToolPreInvokePayload(name="patient_reader", args={}) - context1 = GlobalContext(request_id="req1", tenant_id="healthcare") - assert payload_matches(payload1, "tool_pre_invoke", [condition1, condition2], context1) is True - - # Test 2: Prod + admin user → should execute - payload2 = ToolPreInvokePayload(name="any_tool", args={}) - context2 = GlobalContext(request_id="req2", server_id="prod", user="admin_alice") - assert payload_matches(payload2, "tool_pre_invoke", [condition1, condition2], context2) is True - - # Test 3: Healthcare + other tool → should NOT execute - payload3 = ToolPreInvokePayload(name="other_tool", args={}) - context3 = GlobalContext(request_id="req3", tenant_id="healthcare") - assert payload_matches(payload3, "tool_pre_invoke", [condition1, condition2], context3) is False - - # Test 4: Prod + regular user → should NOT execute - payload4 = ToolPreInvokePayload(name="any_tool", args={}) - context4 = GlobalContext(request_id="req4", server_id="prod", user="regular_user") - assert payload_matches(payload4, "tool_pre_invoke", [condition1, condition2], context4) is False - - -def test_three_field_and_logic(): - """Test AND logic with three fields in one condition object.""" - condition = PluginCondition(tenant_ids={"healthcare"}, tools={"patient_reader"}, user_patterns=["doctor"]) - - payload = ToolPreInvokePayload(name="patient_reader", args={}) - - # All three fields match - context_all_match = GlobalContext(request_id="req1", tenant_id="healthcare", user="doctor_smith") - assert payload_matches(payload, "tool_pre_invoke", [condition], context_all_match) is True - - # Two fields match, one doesn't - context_two_match = GlobalContext( - request_id="req2", - tenant_id="healthcare", - user="nurse_jones", # User pattern doesn't match - ) - assert payload_matches(payload, "tool_pre_invoke", [condition], context_two_match) is False - - -def test_multiple_values_in_set_fields(): - """Test that multiple values in set fields work correctly with AND logic.""" - condition = PluginCondition(tenant_ids={"healthcare", "finance", "legal"}, tools={"tool1", "tool2", "tool3"}) - - # Test with first tenant and first tool - payload1 = ToolPreInvokePayload(name="tool1", args={}) - context1 = GlobalContext(request_id="req1", tenant_id="healthcare") - assert payload_matches(payload1, "tool_pre_invoke", [condition], context1) is True - - # Test with last tenant and last tool - payload2 = ToolPreInvokePayload(name="tool3", args={}) - context2 = GlobalContext(request_id="req2", tenant_id="legal") - assert payload_matches(payload2, "tool_pre_invoke", [condition], context2) is True - - # Test with tenant in set but tool not in set - payload3 = ToolPreInvokePayload(name="other_tool", args={}) - context3 = GlobalContext(request_id="req3", tenant_id="healthcare") - assert payload_matches(payload3, "tool_pre_invoke", [condition], context3) is False - - -# ============================================================================ -# Test matches() function with AND logic -# ============================================================================ - - -def test_matches_all_fields_match(): - """Test matches() with all GlobalContext fields matching.""" - condition = PluginCondition(server_ids={"srv1"}, tenant_ids={"tenant1"}, user_patterns=["admin"]) - context = GlobalContext(request_id="req1", server_id="srv1", tenant_id="tenant1", user="admin_user") - - assert matches(condition, context) is True - - -def test_matches_one_field_mismatch(): - """Test matches() fails if one field doesn't match.""" - condition = PluginCondition(server_ids={"srv1"}, tenant_ids={"tenant1"}, user_patterns=["admin"]) - - # Server matches, tenant matches, user doesn't - context = GlobalContext(request_id="req1", server_id="srv1", tenant_id="tenant1", user="regular_user") - - assert matches(condition, context) is False - - -def test_matches_no_conditions_matches_all(): - """Test matches() with no conditions set matches all.""" - condition = PluginCondition() - context = GlobalContext(request_id="req1") - - assert matches(condition, context) is True - - -# ============================================================================ -# Test Fail-Fast Optimization -# ============================================================================ - - -def test_fail_fast_within_condition_object(): - """Test that evaluation stops at first non-matching field within a condition object.""" - # This is more of a behavioral test - we can't directly test short-circuiting - # but we can verify the result is correct - condition = PluginCondition( - server_ids={"srv1"}, # This will fail - tenant_ids={"tenant1"}, - tools={"tool1"}, - ) - - payload = ToolPreInvokePayload(name="tool1", args={}) - context = GlobalContext( - request_id="req1", - server_id="srv2", # Doesn't match - tenant_id="tenant1", - ) - - # Should fail fast on server_id mismatch - assert payload_matches(payload, "tool_pre_invoke", [condition], context) is False - - -def test_fail_fast_across_condition_objects(): - """Test that evaluation stops at first fully matching condition object.""" - condition1 = PluginCondition(tools={"tool1"}) # This will match - condition2 = PluginCondition(tools={"tool2"}) # This won't be evaluated - - payload = ToolPreInvokePayload(name="tool1", args={}) - context = GlobalContext(request_id="req1") - - # Should match on first condition and not evaluate second - assert payload_matches(payload, "tool_pre_invoke", [condition1, condition2], context) is True diff --git a/tests/unit/cpex/framework/test_validators.py b/tests/unit/cpex/framework/test_validators.py deleted file mode 100644 index a0d2bfad..00000000 --- a/tests/unit/cpex/framework/test_validators.py +++ /dev/null @@ -1,312 +0,0 @@ -# -*- coding: utf-8 -*- -"""Location: ./tests/unit/cpex/framework/test_validators.py -Copyright 2026 -SPDX-License-Identifier: Apache-2.0 -Authors: Fred Araujo - -Tests for the framework's self-contained SecurityValidator. -""" - -# Standard -from unittest.mock import patch - -# Third-Party -import pytest - -# First-Party -from cpex.framework.validators import SecurityValidator - - -class TestSecurityValidatorUrl: - """Tests for SecurityValidator.validate_url.""" - - def test_valid_https_url(self): - result = SecurityValidator.validate_url("https://example.com") - assert result == "https://example.com" - - def test_valid_http_url(self): - result = SecurityValidator.validate_url("http://example.com:8080/mcp") - assert result == "http://example.com:8080/mcp" - - def test_valid_ws_url(self): - result = SecurityValidator.validate_url("ws://example.com:9000") - assert result == "ws://example.com:9000" - - def test_valid_wss_url(self): - result = SecurityValidator.validate_url("wss://secure.example.com/ws") - assert result == "wss://secure.example.com/ws" - - def test_empty_url_raises(self): - with pytest.raises(ValueError, match="cannot be empty"): - SecurityValidator.validate_url("") - - def test_empty_url_with_field_name(self): - with pytest.raises(ValueError, match="Server URL cannot be empty"): - SecurityValidator.validate_url("", field_name="Server URL") - - def test_url_exceeds_max_length(self): - long_url = "https://example.com/" + "a" * 2048 - with pytest.raises(ValueError, match="exceeds maximum length"): - SecurityValidator.validate_url(long_url) - - def test_disallowed_scheme_ftp(self): - with pytest.raises(ValueError, match="must start with one of"): - SecurityValidator.validate_url("ftp://example.com") - - def test_disallowed_scheme_file(self): - with pytest.raises(ValueError, match="must start with one of"): - SecurityValidator.validate_url("file:///etc/passwd") - - def test_url_with_newline(self): - with pytest.raises(ValueError, match="contains line breaks"): - SecurityValidator.validate_url("https://example.com\n/malicious") - - def test_url_with_carriage_return(self): - with pytest.raises(ValueError, match="contains line breaks"): - SecurityValidator.validate_url("https://example.com\r/malicious") - - def test_url_missing_netloc(self): - with pytest.raises(ValueError, match="is not a valid URL"): - SecurityValidator.validate_url("http://") - - def test_urlparse_generic_exception(self): - with patch("cpex.framework.validators.urlparse", side_effect=RuntimeError("parse failure")): - with pytest.raises(ValueError, match="is not a valid URL"): - SecurityValidator.validate_url("https://example.com") - - def test_case_insensitive_scheme(self): - result = SecurityValidator.validate_url("HTTPS://EXAMPLE.COM") - assert result == "HTTPS://EXAMPLE.COM" - - # --- Always-enforced hardening checks --- - - def test_credentials_in_url_rejected(self): - with pytest.raises(ValueError, match="contains credentials"): - SecurityValidator.validate_url("https://user:pass@example.com/") - - def test_username_only_in_url_rejected(self): - with pytest.raises(ValueError, match="contains credentials"): - SecurityValidator.validate_url("https://user@example.com/") - - def test_ipv6_url_rejected(self): - with pytest.raises(ValueError, match="IPv6"): - SecurityValidator.validate_url("https://[::1]:8080/") - - def test_ipv6_full_address_rejected(self): - with pytest.raises(ValueError, match="IPv6"): - SecurityValidator.validate_url("https://[2001:db8::1]/") - - def test_dangerous_protocol_javascript(self): - with pytest.raises(ValueError, match="dangerous protocol"): - SecurityValidator.validate_url("https://example.com?r=javascript:alert(1)") - - def test_dangerous_protocol_data(self): - with pytest.raises(ValueError, match="dangerous protocol"): - SecurityValidator.validate_url("https://example.com?r=data:text/html,

hi

") - - def test_zero_address_always_blocked(self): - """0.0.0.0 is always blocked regardless of ssrf_protection_enabled.""" - with pytest.raises(ValueError, match="invalid IP address"): - SecurityValidator.validate_url("https://0.0.0.0/") - - def test_spaces_in_domain_rejected(self): - with pytest.raises(ValueError, match="contains spaces"): - SecurityValidator.validate_url("https://exam ple.com") - - def test_spaces_in_query_allowed(self): - result = SecurityValidator.validate_url("https://example.com/path?query=hello world") - assert result == "https://example.com/path?query=hello world" - - def test_invalid_port_zero(self): - with pytest.raises(ValueError, match="invalid port"): - SecurityValidator.validate_url("https://example.com:0/") - - def test_public_hostname_allowed(self): - result = SecurityValidator.validate_url("https://my-plugin-server.example.com:9000/sse") - assert result == "https://my-plugin-server.example.com:9000/sse" - - -class TestSecurityValidatorSsrf: - """Tests for configurable SSRF IP-range blocking.""" - - def test_loopback_blocked_when_ssrf_enabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "true") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - with pytest.raises(ValueError, match="blocked by SSRF"): - SecurityValidator.validate_url("https://127.0.0.1/") - finally: - settings.cache_clear() - - def test_link_local_blocked_when_ssrf_enabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "true") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - with pytest.raises(ValueError, match="blocked by SSRF"): - SecurityValidator.validate_url("https://169.254.169.254/") - finally: - settings.cache_clear() - - def test_private_10_blocked_when_ssrf_enabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "true") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - with pytest.raises(ValueError, match="blocked by SSRF"): - SecurityValidator.validate_url("https://10.0.0.1/") - finally: - settings.cache_clear() - - def test_private_172_blocked_when_ssrf_enabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "true") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - with pytest.raises(ValueError, match="blocked by SSRF"): - SecurityValidator.validate_url("https://172.16.0.1/") - finally: - settings.cache_clear() - - def test_private_192_blocked_when_ssrf_enabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "true") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - with pytest.raises(ValueError, match="blocked by SSRF"): - SecurityValidator.validate_url("https://192.168.1.1/") - finally: - settings.cache_clear() - - def test_loopback_allowed_when_ssrf_disabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "false") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - result = SecurityValidator.validate_url("https://127.0.0.1:8080/mcp") - assert result == "https://127.0.0.1:8080/mcp" - finally: - settings.cache_clear() - - def test_private_ip_allowed_when_ssrf_disabled(self, monkeypatch): - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "false") - from cpex.framework.settings import settings - - settings.cache_clear() - try: - result = SecurityValidator.validate_url("https://192.168.1.100:9000/sse") - assert result == "https://192.168.1.100:9000/sse" - finally: - settings.cache_clear() - - -class TestSecurityValidatorSettingsIsolation: - """URL validation must not fail due to unrelated plugin env var errors.""" - - def test_url_validation_succeeds_with_malformed_unrelated_env(self, monkeypatch): - """A malformed PLUGINS_SERVER_PORT should not break URL validation.""" - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - settings.cache_clear() - try: - result = SecurityValidator.validate_url("https://example.com/api") - assert result == "https://example.com/api" - finally: - settings.cache_clear() - - def test_ssrf_check_works_with_malformed_unrelated_env(self, monkeypatch): - """SSRF blocking should work even when unrelated env vars are malformed.""" - from cpex.framework.settings import settings - - monkeypatch.setenv("PLUGINS_SERVER_PORT", "not_a_number") - monkeypatch.setenv("PLUGINS_SSRF_PROTECTION_ENABLED", "true") - settings.cache_clear() - try: - with pytest.raises(ValueError, match="blocked by SSRF"): - SecurityValidator.validate_url("https://10.0.0.1/") - finally: - settings.cache_clear() - - -class TestUrlHtmlJsPatternBlocking: - """Tests for HTML/JS XSS pattern detection within validate_url.""" - - def test_html_tag_in_url_rejected(self): - with pytest.raises(ValueError, match="HTML tags"): - SecurityValidator.validate_url("https://example.com/") - - def test_js_event_handler_in_url_rejected(self): - with pytest.raises(ValueError, match="script patterns"): - SecurityValidator.validate_url("https://example.com/page?q=onclick=alert(1)") - - -class TestDangerousPatternDetection: - """Functional tests for HTML/JS XSS pattern detection.""" - - @pytest.mark.parametrize( - "html", - [ - "", - "", - "