From bc26a7daa3fe98f6d8f269957175b508ada251dd Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Tue, 14 Apr 2026 16:53:21 -0600 Subject: [PATCH 1/8] feat: initial revision rust core. Signed-off-by: Teryl Taylor --- Cargo.lock | 724 ++++++++++++++++++++++++ Cargo.toml | 30 + crates/README.md | 209 +++++++ crates/cpex-core/Cargo.toml | 27 + crates/cpex-core/src/config.rs | 15 + crates/cpex-core/src/context.rs | 123 ++++ crates/cpex-core/src/error.rs | 127 +++++ crates/cpex-core/src/executor.rs | 566 ++++++++++++++++++ crates/cpex-core/src/hooks/adapter.rs | 125 ++++ crates/cpex-core/src/hooks/macros.rs | 105 ++++ crates/cpex-core/src/hooks/mod.rs | 29 + crates/cpex-core/src/hooks/payload.rs | 174 ++++++ crates/cpex-core/src/hooks/trait_def.rs | 267 +++++++++ crates/cpex-core/src/hooks/types.rs | 190 +++++++ crates/cpex-core/src/lib.rs | 30 + crates/cpex-core/src/manager.rs | 656 +++++++++++++++++++++ crates/cpex-core/src/plugin.rs | 414 ++++++++++++++ crates/cpex-core/src/registry.rs | 621 ++++++++++++++++++++ crates/cpex-sdk/Cargo.toml | 22 + crates/cpex-sdk/src/lib.rs | 28 + 20 files changed, 4482 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..5cbba123 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,724 @@ +# 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", + "paste", + "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 = "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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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 = "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..39710358 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +# 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" 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..57421209 --- /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 } +paste = { 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..bd65d032 --- /dev/null +++ b/crates/cpex-core/src/context.rs @@ -0,0 +1,123 @@ +// Location: ./crates/cpex-core/src/context.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Execution context types. +// +// Provides GlobalContext (shared across all plugins for a request) and +// PluginContext (per-plugin, per-invocation). These carry transient +// execution state — counters, caches, intermediate results. All data +// needed for policy evaluation comes from the payload's extensions +// (filtered by capabilities), not from context. +// +// Mirrors the Python framework's GlobalContext and PluginContext types +// in cpex/framework/models.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Global Context +// --------------------------------------------------------------------------- + +/// Shared execution context for a single request. +/// +/// Visible to all plugins during a hook invocation. Plugins can read +/// and contribute to `state` (mutable shared state) and read `metadata` +/// (read-only shared metadata set by the host). +/// +/// # Fields +/// +/// * `request_id` — Unique identifier for the current request. +/// * `user` — User identifier or principal (string or structured). +/// * `tenant_id` — Optional multi-tenant scope. +/// * `server_id` — Optional virtual server scope. +/// * `state` — Mutable shared state merged between plugins. +/// * `metadata` — Read-only metadata set by the host before dispatch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalContext { + /// Unique request identifier (typically a UUID). + pub request_id: String, + + /// User identifier or principal. + #[serde(default)] + pub user: Value, + + /// Multi-tenant scope identifier. + #[serde(default)] + pub tenant_id: Option, + + /// Virtual server scope identifier. + #[serde(default)] + pub server_id: Option, + + /// Mutable shared state — plugins can read and contribute. + /// Merged back after each plugin execution. + #[serde(default)] + pub state: HashMap, + + /// Read-only shared metadata set by the host. + #[serde(default)] + pub metadata: HashMap, +} + +impl GlobalContext { + /// Create a new context with just a request ID. + pub fn new(request_id: impl Into) -> Self { + Self { + request_id: request_id.into(), + user: Value::Null, + tenant_id: None, + server_id: None, + state: HashMap::new(), + metadata: HashMap::new(), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin Context +// --------------------------------------------------------------------------- + +/// Per-plugin, per-invocation execution context. +/// +/// Each plugin receives its own `PluginContext` with isolated local +/// state and a reference to the shared global context. The framework +/// deep-copies the global context per plugin to prevent cross-plugin +/// state leakage. +/// +/// This type carries transient execution state only — counters, caches, +/// intermediate results. All data needed for policy evaluation comes +/// from the payload's extensions (filtered by capabilities). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginContext { + /// Plugin-local state. Private to this plugin, this invocation. + #[serde(default)] + pub local_state: HashMap, + + /// Snapshot of the global context for this invocation. + pub global_context: GlobalContext, +} + +impl PluginContext { + /// Create a new plugin context from a global context. + pub fn new(global_context: GlobalContext) -> Self { + Self { + local_state: HashMap::new(), + global_context, + } + } + + /// 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); + } +} 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..c7f250b3 --- /dev/null +++ b/crates/cpex-core/src/executor.rs @@ -0,0 +1,566 @@ +// 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::time::Duration; + +use tokio::time::timeout; +use tracing::{error, warn}; + +use crate::context::{GlobalContext, PluginContext}; +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, and any violation. Mirrors +/// the Python `PipelineResult` with factory methods. +#[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, +} + +impl PipelineResult { + /// Pipeline completed — all plugins allowed. + pub fn allowed_with(payload: Box, extensions: Extensions) -> Self { + Self { + allowed: true, + payload: Some(payload), + extensions, + violation: None, + } + } + + /// Pipeline was denied by a plugin. + pub fn denied(violation: crate::error::PluginViolation, extensions: Extensions) -> Self { + Self { + allowed: false, + payload: None, + extensions, + violation: Some(violation), + } + } +} + +// --------------------------------------------------------------------------- +// 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). + /// * `global_ctx` — Shared request context. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload, extensions, and + /// any violation. + pub async fn execute( + &self, + entries: &[HookEntry], + payload: Box, + extensions: Extensions, + global_ctx: &GlobalContext, + ) -> PipelineResult { + if entries.is_empty() { + return PipelineResult::allowed_with(payload, extensions); + } + + // 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, + global_ctx, + true, // can_block + true, // can_modify + "SEQUENTIAL", + ) + .await + { + return PipelineResult::denied(v, current_extensions); + } + + // 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, + global_ctx, + 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, global_ctx, "AUDIT") + .await; + + // Phase 4: CONCURRENT — parallel, can block, cannot modify + if let Some(violation) = self + .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, global_ctx) + .await + { + return PipelineResult::denied(violation, current_extensions); + } + + // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results + self.run_ref_phase( + &fire_and_forget, + &*current_payload, + ¤t_extensions, + global_ctx, + "FIRE_AND_FORGET", + ) + .await; + + PipelineResult::allowed_with(current_payload, current_extensions) + } + + // ----------------------------------------------------------------------- + // 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. + async fn run_serial_phase( + &self, + entries: &[HookEntry], + payload: &mut Box, + extensions: &mut Extensions, + global_ctx: &GlobalContext, + can_block: bool, + can_modify: bool, + phase_label: &str, + ) -> Option { + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let ctx = PluginContext::new(global_ctx.clone()); + let on_error = entry.plugin_ref.trusted_config().on_error; + + // 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, async { + entry.handler.invoke(&**payload, &filtered, &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(v) = erased.violation { + 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; + } + } + } + // If extract failed or no modifications — payload unchanged + } + Ok(Err(e)) => { + error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); + match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + )); + } + OnError::Ignore | OnError::Disable => { + // Payload unchanged — continue + } + } + } + Err(_) => { + error!("{} plugin '{}' timed out", phase_label, plugin_name); + match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + )); + } + OnError::Ignore | OnError::Disable => { + // Payload unchanged — continue + } + } + } + } + } + + 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, + global_ctx: &GlobalContext, + phase_label: &str, + ) { + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let ctx = PluginContext::new(global_ctx.clone()); + let filtered = FilteredExtensions::default(); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let result = timeout(timeout_dur, async { + entry.handler.invoke(payload, &filtered, &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 in parallel. + /// Returns the first violation if any plugin denies. + async fn run_concurrent_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + _extensions: &Extensions, + global_ctx: &GlobalContext, + ) -> Option { + if entries.is_empty() { + return None; + } + + // For concurrent, all plugins receive the same read-only payload. + // We collect results and check for denials. + let mut results = Vec::with_capacity(entries.len()); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let on_error = entry.plugin_ref.trusted_config().on_error; + let ctx = PluginContext::new(global_ctx.clone()); + let filtered = FilteredExtensions::default(); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let result = timeout(timeout_dur, async { + entry.handler.invoke(payload, &filtered, &ctx) + }) + .await; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + if !erased.continue_processing { + let violation = erased.violation.unwrap_or_else(|| { + crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ) + }); + if self.config.short_circuit_on_deny { + return Some(violation); + } + results.push(Some(violation)); + } + } + } + Ok(Err(e)) => match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + )); + } + _ => { + warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); + } + }, + Err(_) => match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + )); + } + _ => { + warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); + } + }, + } + } + + // Return first denial if any were collected (non-short-circuit mode) + results.into_iter().flatten().next() + } +} + +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. +pub fn extract_erased(result: Box) -> Option { + result.downcast::().ok().map(|b| *b) +} + +/// 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()); + 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()); + 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 ctx = GlobalContext::new("req-1"); + + let result = executor + .execute(&[], payload, Extensions::default(), &ctx) + .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..ee96ad1a --- /dev/null +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -0,0 +1,125 @@ +// 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, + } + } +} + +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. + fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &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 + } +} + +// Send + Sync: safe because P: Send + Sync (from Plugin bound) +// and H is a zero-sized marker with no data. +unsafe impl Send for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ +} + +unsafe impl Sync for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ +} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs new file mode 100644 index 00000000..5040d3f5 --- /dev/null +++ b/crates/cpex-core/src/hooks/macros.rs @@ -0,0 +1,105 @@ +// 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, trait implementation, and +// a handler trait 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). +// +// The generated handler trait has a single method whose name is +// derived from the hook name. The handler receives: +// - payload: the typed payload (owned — executor decides borrow vs clone) +// - extensions: &FilteredExtensions (capability-gated, separate from payload) +// - ctx: &PluginContext +// +// And returns the hook's result type (typically PluginResult). + +/// Generates a hook type definition, marker struct, and handler trait. +/// +/// # Usage +/// +/// ```rust,ignore +/// define_hook! { +/// /// Doc comment for the hook. +/// MyHook, "my_hook" => { +/// payload: MyPayload, +/// result: PluginResult, +/// } +/// } +/// ``` +/// +/// This generates: +/// +/// 1. A marker struct `MyHook` implementing `HookTypeDef`. +/// 2. A handler trait `MyHookHandler` with a method `my_hook()`. +/// +/// The handler method receives: +/// - `payload: MyPayload` (owned) +/// - `extensions: &FilteredExtensions` +/// - `ctx: &PluginContext` +/// +/// And returns `PluginResult`. +/// +/// # 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: +/// // registry.register_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; + } + + paste::paste! { + /// Handler trait for the + #[doc = concat!("`", stringify!($name), "`")] + /// hook. Implement this on your plugin to handle this hook type. + pub trait [<$name Handler>]: $crate::plugin::Plugin + Send + Sync { + /// Handle the + #[doc = concat!("`", $hook_name, "`")] + /// hook. + /// + /// The executor decides whether to pass a clone (for Sequential/ + /// Transform modes) or a borrow (for Audit/Concurrent/FireAndForget + /// modes) based on the plugin's mode. The handler signature always + /// takes owned payload — the executor handles the mechanics. + fn [<$hook_name>]( + &self, + payload: $payload, + extensions: &$crate::hooks::payload::FilteredExtensions, + ctx: &$crate::context::PluginContext, + ) -> $result; + } + } + }; +} 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..34630778 --- /dev/null +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -0,0 +1,267 @@ +// 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: &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..0caf440e --- /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`] — GlobalContext and PluginContext +// - [`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..2736424f --- /dev/null +++ b/crates/cpex-core/src/manager.rs @@ -0,0 +1,656 @@ +// 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::GlobalContext; +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, + + /// Manager configuration. + config: ManagerConfig, + + /// 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.clone()), + config, + 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 }) + } + + /// Register a plugin using hook names from its config (legacy path). + /// + /// No typed handler — the plugin is registered in the name index + /// only. Used for backward compatibility with plugins that don't + /// use the typed hook system. + pub fn register_legacy( + &mut self, + plugin: Arc, + config: PluginConfig, + ) -> Result<(), PluginError> { + self.registry + .register_legacy(plugin, config) + .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() + ); + + 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); + return Err(PluginError::Execution { + plugin_name, + message: format!("initialization failed: {}", e), + source: Some(Box::new(e)), + }); + } + } + } + + 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). + /// * `global_ctx` — shared request context. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload, extensions, and + /// any violation. + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + global_ctx: &GlobalContext, + ) -> 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); + } + + self.executor + .execute(entries, payload, extensions, global_ctx) + .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. + /// * `global_ctx` — shared request context. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload (type-erased — + /// caller downcasts via `as_any()`), extensions, and any violation. + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + global_ctx: &GlobalContext, + ) -> 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); + } + + let boxed: Box = Box::new(payload); + self.executor + .execute(entries, boxed, extensions, global_ctx) + .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: &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: &PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new("denied", "test denial")) + } + } + + // -- Helper -- + + fn make_config(name: &str, priority: i32, mode: PluginMode) -> 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: OnError::Fail, + 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 ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .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 ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .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 ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .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 ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke::(payload, Extensions::default(), &ctx) + .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 ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .await; + + // Audit mode — deny is suppressed, pipeline continues + assert!(result.allowed); + } +} 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..03f50ee6 --- /dev/null +++ b/crates/cpex-core/src/registry.rs @@ -0,0 +1,621 @@ +// 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::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, +} + +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, + } + } + + /// 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 + } + + /// Convenience: plugin mode from the trusted config. + pub fn mode(&self) -> PluginMode { + self.trusted_config.mode + } + + /// 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. +/// +/// Two invoke paths: +/// - `invoke_owned()` — for Sequential/Transform modes. Receives +/// an owned payload (Box). The handler downcasts and calls +/// the typed method. +/// - `invoke_ref()` — for Audit/Concurrent/FireAndForget modes. +/// Receives a borrowed payload (&dyn Any). No clone needed. +pub trait AnyHookHandler: Send + Sync { + /// Call the handler with a borrowed payload. + /// + /// The handler downcasts `&dyn PluginPayload` to the concrete + /// type via `as_any()`. The framework always retains ownership + /// of the payload — the handler clones only if it needs to modify. + /// + /// Returns an `ErasedResultFields` (see executor module) wrapped + /// as `Box`. If the handler modified the payload, the + /// modified copy is in `ErasedResultFields.modified_payload`. + fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &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) + } + + /// Register a plugin with its authoritative config using hook names + /// from the config (legacy path — no typed handler). + /// + /// This is the backward-compatible path for plugins that don't use + /// the typed hook system. The plugin is registered in the name index + /// and hook index, but without a type-erased handler. The executor + /// must use `invoke_by_name()` with payload conversion for these. + pub fn register_legacy( + &mut self, + plugin: Arc, + config: PluginConfig, + ) -> 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); + self.plugins.insert(name, plugin_ref); + Ok(()) + } + + /// Internal: register handler under one or more hook names. + fn register_for_names_inner( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = 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; + + impl AnyHookHandler for TestHandler { + fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &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); + } + + #[test] + fn test_handler_invoke() { + let handler = TestHandler; + let payload = TestPayload { + value: "test".into(), + }; + let ext = FilteredExtensions::default(); + let ctx = PluginContext::new(crate::context::GlobalContext::new("req-1")); + + let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &ctx).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 1613aaf7ad7ed0c59f3a0129dab564ad201e0b31 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Tue, 21 Apr 2026 21:01:32 -0600 Subject: [PATCH 2/8] fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor --- Cargo.lock | 102 ++++- Cargo.toml | 1 + crates/cpex-core/Cargo.toml | 2 +- crates/cpex-core/src/context.rs | 152 ++++--- crates/cpex-core/src/executor.rs | 332 ++++++++++---- crates/cpex-core/src/hooks/adapter.rs | 23 +- crates/cpex-core/src/hooks/macros.rs | 57 +-- crates/cpex-core/src/hooks/trait_def.rs | 7 +- crates/cpex-core/src/lib.rs | 2 +- crates/cpex-core/src/manager.rs | 557 ++++++++++++++++++++++-- crates/cpex-core/src/registry.rs | 90 ++-- 11 files changed, 1009 insertions(+), 316 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cbba123..b06faa5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,7 +48,7 @@ name = "cpex-core" version = "0.1.0" dependencies = [ "async-trait", - "paste", + "futures", "serde", "serde_json", "serde_yaml", @@ -90,6 +90,94 @@ 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" @@ -231,12 +319,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -376,6 +458,12 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index 39710358..03fcb104 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ thiserror = "2" tracing = "0.1" uuid = { version = "1", features = ["v4"] } paste = "1" +futures = "0.3" diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 57421209..4e0d4006 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -24,4 +24,4 @@ async-trait = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } -paste = { workspace = true } +futures = { workspace = true } diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs index bd65d032..59e61a8a 100644 --- a/crates/cpex-core/src/context.rs +++ b/crates/cpex-core/src/context.rs @@ -5,109 +5,74 @@ // // Execution context types. // -// Provides GlobalContext (shared across all plugins for a request) and -// PluginContext (per-plugin, per-invocation). These carry transient -// execution state — counters, caches, intermediate results. All data -// needed for policy evaluation comes from the payload's extensions -// (filtered by capabilities), not from context. +// 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. // -// Mirrors the Python framework's GlobalContext and PluginContext types -// in cpex/framework/models.py. +// 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; -// --------------------------------------------------------------------------- -// Global Context -// --------------------------------------------------------------------------- - -/// Shared execution context for a single request. -/// -/// Visible to all plugins during a hook invocation. Plugins can read -/// and contribute to `state` (mutable shared state) and read `metadata` -/// (read-only shared metadata set by the host). -/// -/// # Fields -/// -/// * `request_id` — Unique identifier for the current request. -/// * `user` — User identifier or principal (string or structured). -/// * `tenant_id` — Optional multi-tenant scope. -/// * `server_id` — Optional virtual server scope. -/// * `state` — Mutable shared state merged between plugins. -/// * `metadata` — Read-only metadata set by the host before dispatch. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GlobalContext { - /// Unique request identifier (typically a UUID). - pub request_id: String, - - /// User identifier or principal. - #[serde(default)] - pub user: Value, - - /// Multi-tenant scope identifier. - #[serde(default)] - pub tenant_id: Option, - - /// Virtual server scope identifier. - #[serde(default)] - pub server_id: Option, - - /// Mutable shared state — plugins can read and contribute. - /// Merged back after each plugin execution. - #[serde(default)] - pub state: HashMap, - - /// Read-only shared metadata set by the host. - #[serde(default)] - pub metadata: HashMap, -} - -impl GlobalContext { - /// Create a new context with just a request ID. - pub fn new(request_id: impl Into) -> Self { - Self { - request_id: request_id.into(), - user: Value::Null, - tenant_id: None, - server_id: None, - state: HashMap::new(), - metadata: HashMap::new(), - } - } -} - // --------------------------------------------------------------------------- // Plugin Context // --------------------------------------------------------------------------- /// Per-plugin, per-invocation execution context. /// -/// Each plugin receives its own `PluginContext` with isolated local -/// state and a reference to the shared global context. The framework -/// deep-copies the global context per plugin to prevent cross-plugin -/// state leakage. +/// 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. /// -/// This type carries transient execution state only — counters, caches, -/// intermediate results. All data needed for policy evaluation comes -/// from the payload's extensions (filtered by capabilities). +/// 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, - /// Snapshot of the global context for this invocation. - pub global_context: GlobalContext, + /// 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 plugin context from a global context. - pub fn new(global_context: GlobalContext) -> Self { + /// 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_context, + global_state, } } @@ -120,4 +85,35 @@ impl PluginContext { 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/executor.rs b/crates/cpex-core/src/executor.rs index c7f250b3..4b1188ef 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -25,12 +25,14 @@ // 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::{GlobalContext, PluginContext}; +use crate::context::{PluginContext, PluginContextTable}; use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; use crate::plugin::OnError; use crate::registry::{group_by_mode, HookEntry}; @@ -64,8 +66,10 @@ impl Default for ExecutorConfig { /// Aggregate result from a full hook invocation across all phases. /// -/// Wraps the final payload, extensions, and any violation. Mirrors -/// the Python `PipelineResult` with factory methods. +/// 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. @@ -80,26 +84,40 @@ pub struct PipelineResult { /// 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) -> Self { + 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) -> Self { + pub fn denied( + violation: crate::error::PluginViolation, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { Self { allowed: false, payload: None, extensions, violation: Some(violation), + context_table, } } } @@ -133,23 +151,26 @@ impl Executor { /// # Arguments /// /// * `entries` — HookEntries for this hook, sorted by priority. - /// * `payload` — The typed payload (type-erased as Box). + /// * `payload` — The typed payload (type-erased as Box). /// * `extensions` — The full extensions (filtered per plugin before dispatch). - /// * `global_ctx` — Shared request context. + /// * `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, and - /// any violation. + /// 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, - global_ctx: &GlobalContext, + context_table: Option, ) -> PipelineResult { + let mut ctx_table = context_table.unwrap_or_default(); + if entries.is_empty() { - return PipelineResult::allowed_with(payload, extensions); + return PipelineResult::allowed_with(payload, extensions, ctx_table); } // Group entries by mode (from trusted_config) @@ -165,14 +186,14 @@ impl Executor { &sequential, &mut current_payload, &mut current_extensions, - global_ctx, + &mut ctx_table, true, // can_block true, // can_modify "SEQUENTIAL", ) .await { - return PipelineResult::denied(v, current_extensions); + return PipelineResult::denied(v, current_extensions, ctx_table); } // Phase 2: TRANSFORM — serial, chained, can modify, cannot block @@ -181,7 +202,7 @@ impl Executor { &transform, &mut current_payload, &mut current_extensions, - global_ctx, + &mut ctx_table, false, // can_block true, // can_modify "TRANSFORM", @@ -189,28 +210,25 @@ impl Executor { .await; // Phase 3: AUDIT — serial, read-only, discard results - self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, global_ctx, "AUDIT") + 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, global_ctx) + .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) .await { - return PipelineResult::denied(violation, current_extensions); + return PipelineResult::denied(violation, current_extensions, ctx_table); } // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results - self.run_ref_phase( + self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, - ¤t_extensions, - global_ctx, - "FIRE_AND_FORGET", - ) - .await; + &ctx_table, + ); - PipelineResult::allowed_with(current_payload, current_extensions) + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) } // ----------------------------------------------------------------------- @@ -223,37 +241,54 @@ impl Executor { /// 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, - global_ctx: &GlobalContext, + 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 ctx = PluginContext::new(global_ctx.clone()); + 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, async { - entry.handler.invoke(&**payload, &filtered, &ctx) - }) - .await; + 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(v) = erased.violation { + if let Some(mut v) = erased.violation { + v.plugin_name = Some(plugin_name.clone()); return Some(v); } } @@ -268,6 +303,13 @@ impl Executor { *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 } @@ -275,13 +317,17 @@ impl Executor { error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + 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 => { - // Payload unchanged — continue + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); } } } @@ -289,17 +335,29 @@ impl Executor { error!("{} plugin '{}' timed out", phase_label, plugin_name); match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + 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 => { - // Payload unchanged — continue + 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 @@ -315,19 +373,29 @@ impl Executor { entries: &[HookEntry], payload: &dyn PluginPayload, _extensions: &Extensions, - global_ctx: &GlobalContext, + 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 ctx = PluginContext::new(global_ctx.clone()); + 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, async { - entry.handler.invoke(payload, &filtered, &ctx) - }) - .await; + let result = timeout(timeout_dur, entry.handler.invoke(payload, &filtered, &mut ctx)) + .await; match result { Ok(Ok(_)) => {} // read-only — discard result @@ -345,79 +413,183 @@ impl Executor { // Phase 4: Concurrent (parallel, fail-fast) // ----------------------------------------------------------------------- - /// Run the concurrent phase — plugins execute in parallel. + /// 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, - global_ctx: &GlobalContext, + ctx_table: &PluginContextTable, ) -> Option { if entries.is_empty() { return None; } - // For concurrent, all plugins receive the same read-only payload. - // We collect results and check for denials. - let mut results = Vec::with_capacity(entries.len()); + // 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 plugin_name = entry.plugin_ref.name().to_string(); + 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 ctx = PluginContext::new(global_ctx.clone()); - let filtered = FilteredExtensions::default(); - let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout(timeout_dur, async { - entry.handler.invoke(payload, &filtered, &ctx) - }) - .await; + 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 violation = erased.violation.unwrap_or_else(|| { + 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); } - results.push(Some(violation)); + denials.push(violation); } } } Ok(Err(e)) => match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + 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 => { - return Some(crate::error::PluginViolation::new( + 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) - results.into_iter().flatten().next() + 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); + } + } + }); + } } } @@ -452,8 +624,16 @@ pub struct ErasedResultFields { /// 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 { - result.downcast::().ok().map(|b| *b) + match result.downcast::() { + Ok(b) => Some(*b), + Err(_) => { + warn!("extract_erased: downcast failed — handler returned unexpected type"); + None + } + } } /// Convert a typed `PluginResult

` into `ErasedResultFields`. @@ -534,7 +714,11 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let result = PipelineResult::allowed_with(payload, Extensions::default()); + let result = PipelineResult::allowed_with( + payload, + Extensions::default(), + PluginContextTable::new(), + ); assert!(result.allowed); assert!(result.payload.is_some()); assert!(result.violation.is_none()); @@ -543,7 +727,11 @@ mod tests { #[test] fn test_pipeline_result_denied() { let violation = crate::error::PluginViolation::new("test", "denied"); - let result = PipelineResult::denied(violation, Extensions::default()); + let result = PipelineResult::denied( + violation, + Extensions::default(), + PluginContextTable::new(), + ); assert!(!result.allowed); assert!(result.payload.is_none()); assert!(result.violation.is_some()); @@ -555,10 +743,8 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); - let result = executor - .execute(&[], payload, Extensions::default(), &ctx) + .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 index ee96ad1a..e0339b95 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -65,6 +65,7 @@ where } } +#[async_trait::async_trait] impl AnyHookHandler for TypedHandlerAdapter where H: HookTypeDef, @@ -78,11 +79,11 @@ where /// receives a borrow (`&H::Payload`) and clones only if it needs /// to modify. The result is erased back to `ErasedResultFields` /// for the executor. - fn invoke( + async fn invoke( &self, payload: &dyn PluginPayload, extensions: &FilteredExtensions, - ctx: &PluginContext, + ctx: &mut PluginContext, ) -> Result, PluginError> { let typed_ref: &H::Payload = payload .as_any() @@ -105,21 +106,3 @@ where H::NAME } } - -// Send + Sync: safe because P: Send + Sync (from Plugin bound) -// and H is a zero-sized marker with no data. -unsafe impl Send for TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ -} - -unsafe impl Sync for TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ -} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs index 5040d3f5..80012acf 100644 --- a/crates/cpex-core/src/hooks/macros.rs +++ b/crates/cpex-core/src/hooks/macros.rs @@ -5,20 +5,16 @@ // // define_hook! macro. // -// Generates a HookTypeDef marker struct, trait implementation, and -// a handler trait 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). +// 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). // -// The generated handler trait has a single method whose name is -// derived from the hook name. The handler receives: -// - payload: the typed payload (owned — executor decides borrow vs clone) -// - extensions: &FilteredExtensions (capability-gated, separate from payload) -// - ctx: &PluginContext -// -// And returns the hook's result type (typically PluginResult). +// 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, marker struct, and handler trait. +/// Generates a hook type definition and marker struct. /// /// # Usage /// @@ -32,17 +28,8 @@ /// } /// ``` /// -/// This generates: -/// -/// 1. A marker struct `MyHook` implementing `HookTypeDef`. -/// 2. A handler trait `MyHookHandler` with a method `my_hook()`. -/// -/// The handler method receives: -/// - `payload: MyPayload` (owned) -/// - `extensions: &FilteredExtensions` -/// - `ctx: &PluginContext` -/// -/// And returns `PluginResult`. +/// This generates a marker struct `MyHook` implementing `HookTypeDef`. +/// Plugins handle it by implementing `HookHandler`. /// /// # CMF Pattern (one handler, multiple hook names) /// @@ -58,7 +45,7 @@ /// } /// /// // Register the same handler for multiple names: -/// // registry.register_for_names::(plugin, config, &[ +/// // manager.register_handler_for_names::(plugin, config, &[ /// // "cmf.tool_pre_invoke", "cmf.llm_input", ... /// // ]); /// ``` @@ -79,27 +66,5 @@ macro_rules! define_hook { type Result = $result; const NAME: &'static str = $hook_name; } - - paste::paste! { - /// Handler trait for the - #[doc = concat!("`", stringify!($name), "`")] - /// hook. Implement this on your plugin to handle this hook type. - pub trait [<$name Handler>]: $crate::plugin::Plugin + Send + Sync { - /// Handle the - #[doc = concat!("`", $hook_name, "`")] - /// hook. - /// - /// The executor decides whether to pass a clone (for Sequential/ - /// Transform modes) or a borrow (for Audit/Concurrent/FireAndForget - /// modes) based on the plugin's mode. The handler signature always - /// takes owned payload — the executor handles the mechanics. - fn [<$hook_name>]( - &self, - payload: $payload, - extensions: &$crate::hooks::payload::FilteredExtensions, - ctx: &$crate::context::PluginContext, - ) -> $result; - } - } }; } diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index 34630778..a437c955 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -116,7 +116,7 @@ pub trait HookHandler: Plugin + Send + Sync { &self, payload: &H::Payload, extensions: &FilteredExtensions, - ctx: &PluginContext, + ctx: &mut PluginContext, ) -> H::Result; } @@ -195,6 +195,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: None, modified_extensions: None, + violation: None, metadata: None, } @@ -206,6 +207,7 @@ impl PluginResult

{ continue_processing: false, modified_payload: None, modified_extensions: None, + violation: Some(violation), metadata: None, } @@ -217,6 +219,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: Some(payload), modified_extensions: None, + violation: None, metadata: None, } @@ -228,6 +231,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: None, modified_extensions: Some(extensions), + violation: None, metadata: None, } @@ -239,6 +243,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: Some(payload), modified_extensions: Some(extensions), + violation: None, metadata: None, } diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index 0caf440e..2743b238 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -17,7 +17,7 @@ // - [`manager`] — PluginManager lifecycle and hook dispatch // - [`registry`] — PluginInstanceRegistry and HookRegistry // - [`config`] — Unified YAML configuration parsing -// - [`context`] — GlobalContext and PluginContext +// - [`context`] — PluginContext (local_state + global_state) // - [`error`] — Error types, violations, and result types pub mod config; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 2736424f..a2a6e8a3 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use tracing::{error, info}; -use crate::context::GlobalContext; +use crate::context::PluginContextTable; use crate::error::PluginError; use crate::executor::{Executor, ExecutorConfig, PipelineResult}; use crate::hooks::adapter::TypedHandlerAdapter; @@ -97,9 +97,6 @@ pub struct PluginManager { /// Executor — stateless 5-phase pipeline engine. executor: Executor, - /// Manager configuration. - config: ManagerConfig, - /// Whether initialize() has been called. initialized: bool, } @@ -109,8 +106,7 @@ impl PluginManager { pub fn new(config: ManagerConfig) -> Self { Self { registry: PluginRegistry::new(), - executor: Executor::new(config.executor.clone()), - config, + executor: Executor::new(config.executor), initialized: false, } } @@ -203,21 +199,6 @@ impl PluginManager { .map_err(|msg| PluginError::Config { message: msg }) } - /// Register a plugin using hook names from its config (legacy path). - /// - /// No typed handler — the plugin is registered in the name index - /// only. Used for backward compatibility with plugins that don't - /// use the typed hook system. - pub fn register_legacy( - &mut self, - plugin: Arc, - config: PluginConfig, - ) -> Result<(), PluginError> { - self.registry - .register_legacy(plugin, config) - .map_err(|msg| PluginError::Config { message: msg }) - } - // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- @@ -237,6 +218,8 @@ impl PluginManager { 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(); @@ -244,12 +227,27 @@ impl PluginManager { 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); } } @@ -300,28 +298,34 @@ impl PluginManager { /// * `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). - /// * `global_ctx` — shared request context. + /// * `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, and - /// any violation. + /// 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, - global_ctx: &GlobalContext, + 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); + return PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ); } self.executor - .execute(entries, payload, extensions, global_ctx) + .execute(entries, payload, extensions, context_table) .await } @@ -344,29 +348,34 @@ impl PluginManager { /// /// * `payload` — the typed payload. /// * `extensions` — the full extensions. - /// * `global_ctx` — shared request context. + /// * `context_table` — optional context table from a previous hook. /// /// # Returns /// /// A `PipelineResult` with the final payload (type-erased — - /// caller downcasts via `as_any()`), extensions, and any violation. + /// caller downcasts via `as_any()`), extensions, violation, and + /// the updated context table. pub async fn invoke( &self, payload: H::Payload, extensions: Extensions, - global_ctx: &GlobalContext, + 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); + return PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ); } let boxed: Box = Box::new(payload); self.executor - .execute(entries, boxed, extensions, global_ctx) + .execute(entries, boxed, extensions, context_table) .await } @@ -458,7 +467,7 @@ mod tests { &self, _payload: &TestPayload, _extensions: &FilteredExtensions, - _ctx: &PluginContext, + _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::allow() } @@ -481,15 +490,47 @@ mod tests { &self, _payload: &TestPayload, _extensions: &FilteredExtensions, - _ctx: &PluginContext, + _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::deny(PluginViolation::new("denied", "test denial")) } } - // -- Helper -- + /// 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(), @@ -499,7 +540,7 @@ mod tests { hooks: vec!["test_hook".to_string()], mode, priority, - on_error: OnError::Fail, + on_error, capabilities: Default::default(), tags: Vec::new(), conditions: Vec::new(), @@ -531,10 +572,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; assert!(result.allowed); @@ -554,10 +595,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; assert!(result.allowed); @@ -575,10 +616,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; assert!(!result.allowed); @@ -597,10 +638,10 @@ mod tests { let payload = TestPayload { value: "typed".into(), }; - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke::(payload, Extensions::default(), &ctx) + .invoke::(payload, Extensions::default(), None) .await; assert!(result.allowed); @@ -644,13 +685,437 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .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/registry.rs b/crates/cpex-core/src/registry.rs index 03f50ee6..cbc88a9c 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -30,6 +30,7 @@ // 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; @@ -68,6 +69,12 @@ pub struct PluginRef { /// 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 { @@ -82,6 +89,7 @@ impl PluginRef { plugin, trusted_config, id, + disabled: Arc::new(AtomicBool::new(false)), } } @@ -105,9 +113,28 @@ impl PluginRef { &self.trusted_config.name } - /// Convenience: plugin mode from the trusted config. + /// Effective mode — returns `Disabled` if the runtime circuit breaker + /// has tripped, otherwise returns the configured mode. pub fn mode(&self) -> PluginMode { - self.trusted_config.mode + 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. @@ -128,27 +155,26 @@ impl PluginRef { /// `CmfHookHandler`) and translates between type-erased payloads /// and the typed handler method. /// -/// Two invoke paths: -/// - `invoke_owned()` — for Sequential/Transform modes. Receives -/// an owned payload (Box). The handler downcasts and calls -/// the typed method. -/// - `invoke_ref()` — for Audit/Concurrent/FireAndForget modes. -/// Receives a borrowed payload (&dyn Any). No clone needed. +/// 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. /// - /// The handler downcasts `&dyn PluginPayload` to the concrete - /// type via `as_any()`. The framework always retains ownership - /// of the payload — the handler clones only if it needs to modify. - /// /// Returns an `ErasedResultFields` (see executor module) wrapped /// as `Box`. If the handler modified the payload, the /// modified copy is in `ErasedResultFields.modified_payload`. - fn invoke( + async fn invoke( &self, payload: &dyn PluginPayload, extensions: &FilteredExtensions, - ctx: &PluginContext, + ctx: &mut PluginContext, ) -> Result, crate::error::PluginError>; /// The hook type name this handler was registered for. @@ -252,29 +278,6 @@ impl PluginRegistry { self.register_for_names_inner(plugin, config, handler, names) } - /// Register a plugin with its authoritative config using hook names - /// from the config (legacy path — no typed handler). - /// - /// This is the backward-compatible path for plugins that don't use - /// the typed hook system. The plugin is registered in the name index - /// and hook index, but without a type-erased handler. The executor - /// must use `invoke_by_name()` with payload conversion for these. - pub fn register_legacy( - &mut self, - plugin: Arc, - config: PluginConfig, - ) -> 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); - self.plugins.insert(name, plugin_ref); - Ok(()) - } - /// Internal: register handler under one or more hook names. fn register_for_names_inner( &mut self, @@ -431,12 +434,13 @@ mod tests { /// A simple AnyHookHandler that wraps a function for testing. struct TestHandler; + #[async_trait] impl AnyHookHandler for TestHandler { - fn invoke( + async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &FilteredExtensions, - _ctx: &PluginContext, + _ctx: &mut PluginContext, ) -> Result, PluginError> { let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) @@ -605,16 +609,16 @@ mod tests { assert_eq!(plugin_ref.priority(), 100); } - #[test] - fn test_handler_invoke() { + #[tokio::test] + async fn test_handler_invoke() { let handler = TestHandler; let payload = TestPayload { value: "test".into(), }; let ext = FilteredExtensions::default(); - let ctx = PluginContext::new(crate::context::GlobalContext::new("req-1")); + let mut ctx = PluginContext::new(); - let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &ctx).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); } From 96bbf2ab17ae72f22bb6bba9b16343b69baa837d Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Tue, 21 Apr 2026 10:51:03 -0600 Subject: [PATCH 3/8] feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor --- Cargo.lock | 9 + Cargo.toml | 1 + crates/cpex-core/Cargo.toml | 1 + crates/cpex-core/src/config.rs | 1146 ++++++++++++++++++++++- crates/cpex-core/src/factory.rs | 136 +++ crates/cpex-core/src/hooks/payload.rs | 46 +- crates/cpex-core/src/lib.rs | 2 + crates/cpex-core/src/manager.rs | 1202 ++++++++++++++++++++++++- crates/cpex-core/src/registry.rs | 17 + 9 files changed, 2546 insertions(+), 14 deletions(-) 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/src/config.rs b/crates/cpex-core/src/config.rs index 02496747..2d6c2d44 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -5,11 +5,1145 @@ // // 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 URI pattern. + #[serde(default)] + pub resource: Option, + + /// Match a prompt by name. + #[serde(default)] + pub prompt: Option, + + /// Match an LLM by model name. + #[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_WITH_SCOPE: usize = 1100; +const SPECIFICITY_EXACT_NAME: usize = 1000; +const SPECIFICITY_NAME_LIST: usize = 500; +const SPECIFICITY_GLOB: usize = 300; +const SPECIFICITY_TAGS_ONLY: usize = 100; +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(pattern) = &route.resource { + if pattern.ends_with('*') { + let prefix = pattern.trim_end_matches('*'); + if !entity_name.starts_with(prefix) { + continue; + } + SPECIFICITY_GLOB + } else if pattern == entity_name { + SPECIFICITY_EXACT_NAME + } else { + continue; + } + } else { + continue; + } + } + "prompt" => { + if route.prompt.as_deref() == Some(entity_name) { + SPECIFICITY_EXACT_NAME + } else { + continue; + } + } + "llm" => { + if route.llm.as_deref() == Some(entity_name) { + 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/factory.rs b/crates/cpex-core/src/factory.rs new file mode 100644 index 00000000..c571920a --- /dev/null +++ b/crates/cpex-core/src/factory.rs @@ -0,0 +1,136 @@ +// 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 handler. +pub struct PluginInstance { + /// The plugin implementation. + pub plugin: Arc, + + /// The type-erased handler for the executor. + pub handler: 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..8e51b362 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 hashbrown::HashMap; use tracing::{error, info}; +use crate::config::{self, CpexConfig}; use crate::context::PluginContextTable; use crate::error::PluginError; use crate::executor::{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,26 @@ pub struct PluginManager { /// Executor — stateless 5-phase pipeline engine. executor: Executor, + /// Manager configuration. + config: ManagerConfig, + + /// 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 +167,169 @@ 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), + executor: Executor::new(config.executor.clone()), + config, + 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)?; + + let hook_names: Vec<&str> = plugin_config.hooks.iter().map(|s| s.as_str()).collect(); + { + self.registry + .register_for_names_with_handler( + instance.plugin, + plugin_config.clone(), + instance.handler, + &hook_names, + ) + .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)?; + + let hook_names: Vec<&str> = plugin_config.hooks.iter().map(|s| s.as_str()).collect(); + { + manager + .registry + .register_for_names_with_handler( + instance.plugin, + plugin_config.clone(), + instance.handler, + &hook_names, + ) + .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 // ----------------------------------------------------------------------- @@ -314,7 +533,17 @@ impl PluginManager { context_table: Option, ) -> PipelineResult { 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(), + ); + } + + let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); if entries.is_empty() { return PipelineResult::allowed_with( @@ -325,12 +554,13 @@ impl PluginManager { } 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 +570,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,7 +582,7 @@ 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 @@ -362,7 +597,18 @@ impl PluginManager { context_table: Option, ) -> PipelineResult { 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(), + ); + } + + let entries = self.filter_entries_by_route(all_entries, &extensions, H::NAME); if entries.is_empty() { let boxed: Box = Box::new(payload); @@ -375,10 +621,180 @@ impl PluginManager { 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 + match factory.create(&merged_config) { + Ok(instance) => { + // Build a HookEntry from the new instance + let plugin_ref = crate::registry::PluginRef::new(instance.plugin, merged_config); + Some(crate::registry::HookEntry { + plugin_ref, + handler: instance.handler, + }) + } + 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 // ----------------------------------------------------------------------- @@ -1118,4 +1534,776 @@ mod tests { 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, 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, 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.allowed); + 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.allowed); // 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.allowed); + // 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.allowed); + } + + // -- 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.allowed); // 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.allowed); // 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.allowed); // 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.allowed || !result.allowed); // 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.allowed); // 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.allowed); // 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.allowed); + + // 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.allowed); + } + + #[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.allowed); + + // 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.allowed); + } } diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index cbc88a9c..b7ae0fef 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -278,6 +278,23 @@ 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) + } + + /// Internal: register handler under one or more hook names. fn register_for_names_inner( &mut self, From ae728201dec11656ee51e187c78b3920af27fe77 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Thu, 23 Apr 2026 13:35:38 -0600 Subject: [PATCH 4/8] feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor --- crates/cpex-core/examples/README.md | 43 +++ crates/cpex-core/examples/plugin_demo.rs | 408 +++++++++++++++++++++ crates/cpex-core/examples/plugin_demo.yaml | 59 +++ crates/cpex-core/src/config.rs | 2 - crates/cpex-core/src/executor.rs | 181 +++++++-- crates/cpex-core/src/factory.rs | 11 +- crates/cpex-core/src/manager.rs | 272 +++++++------- crates/cpex-core/src/registry.rs | 43 +++ 8 files changed, 856 insertions(+), 163 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 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..20d53844 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -0,0 +1,408 @@ +// 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 } + async fn initialize(&self) -> Result<(), PluginError> { + println!(" [pii-guard] initialized"); + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + println!(" [pii-guard] shutdown"); + Ok(()) + } +} + +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 } + async fn initialize(&self) -> Result<(), PluginError> { + println!(" [audit-logger] initialized"); + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + println!(" [audit-logger] shutdown"); + Ok(()) + } +} + +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 2d6c2d44..3b83514e 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -405,11 +405,9 @@ fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { // --------------------------------------------------------------------------- /// Specificity scores for route matching. -const SPECIFICITY_EXACT_NAME_WITH_SCOPE: usize = 1100; const SPECIFICITY_EXACT_NAME: usize = 1000; const SPECIFICITY_NAME_LIST: usize = 500; const SPECIFICITY_GLOB: usize = 300; -const SPECIFICITY_TAGS_ONLY: usize = 100; const SPECIFICITY_WHEN_ONLY: usize = 10; const SPECIFICITY_WILDCARD: usize = 0; diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 4b1188ef..570a4344 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,88 @@ 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, + }); + } + } + 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 +246,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 +288,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 +316,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 +651,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 +672,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 +693,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 +834,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 +847,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 +858,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 index c571920a..e77f80ca 100644 --- a/crates/cpex-core/src/factory.rs +++ b/crates/cpex-core/src/factory.rs @@ -65,13 +65,18 @@ pub trait PluginFactory: Send + Sync { fn create(&self, config: &PluginConfig) -> Result; } -/// A created plugin instance — the plugin and its type-erased handler. +/// 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, - /// The type-erased handler for the executor. - pub handler: 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)>, } // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 8e51b362..31be8543 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -29,12 +29,12 @@ use std::path::Path; use std::sync::{Arc, RwLock}; use hashbrown::HashMap; -use tracing::{error, info}; +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}; @@ -140,9 +140,6 @@ pub struct PluginManager { /// Executor — stateless 5-phase pipeline engine. executor: Executor, - /// Manager configuration. - config: ManagerConfig, - /// Parsed CPEX config (when loaded from file). Used for route resolution. cpex_config: Option, @@ -170,8 +167,7 @@ impl PluginManager { let cache_hasher = hashbrown::DefaultHashBuilder::default(); Self { registry: PluginRegistry::new(), - executor: Executor::new(config.executor.clone()), - config, + executor: Executor::new(config.executor), cpex_config: None, factories: PluginFactoryRegistry::new(), route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())), @@ -254,17 +250,13 @@ impl PluginManager { let instance = factory.create(plugin_config)?; - let hook_names: Vec<&str> = plugin_config.hooks.iter().map(|s| s.as_str()).collect(); - { - self.registry - .register_for_names_with_handler( - instance.plugin, - plugin_config.clone(), - instance.handler, - &hook_names, - ) - .map_err(|msg| PluginError::Config { message: msg })?; - } + 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: {:?}", @@ -306,18 +298,14 @@ impl PluginManager { let instance = factory.create(plugin_config)?; - let hook_names: Vec<&str> = plugin_config.hooks.iter().map(|s| s.as_str()).collect(); - { - manager - .registry - .register_for_names_with_handler( - instance.plugin, - plugin_config.clone(), - instance.handler, - &hook_names, - ) - .map_err(|msg| PluginError::Config { message: msg })?; - } + manager + .registry + .register_multi_handler( + instance.plugin, + plugin_config.clone(), + instance.handlers, + ) + .map_err(|msg| PluginError::Config { message: msg })?; } // Update executor from config settings @@ -523,33 +511,40 @@ 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 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(), + 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(), ); } @@ -587,24 +582,25 @@ impl PluginManager { /// /// # 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 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(), + return ( + PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), ); } @@ -612,10 +608,13 @@ impl PluginManager { 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(), ); } @@ -764,14 +763,30 @@ impl PluginManager { } // Create new instance with merged config + let target_hook = base_entry.handler.hook_type_name(); match factory.create(&merged_config) { Ok(instance) => { - // Build a HookEntry from the new instance - let plugin_ref = crate::registry::PluginRef::new(instance.plugin, merged_config); - Some(crate::registry::HookEntry { - plugin_ref, - handler: instance.handler, - }) + // 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!( @@ -990,12 +1005,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] @@ -1013,11 +1028,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] @@ -1034,11 +1049,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"); } @@ -1056,11 +1071,11 @@ mod tests { }; - let result = mgr + let (result, _) = mgr .invoke::(payload, Extensions::default(), None) .await; - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -1103,12 +1118,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] @@ -1134,8 +1149,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(); @@ -1145,8 +1160,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] @@ -1166,8 +1181,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(); @@ -1192,8 +1207,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(), @@ -1264,10 +1279,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"); } @@ -1318,10 +1333,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()); @@ -1348,11 +1363,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()); @@ -1396,14 +1411,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"); } @@ -1468,9 +1484,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" @@ -1514,8 +1530,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; @@ -1524,10 +1540,10 @@ 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; @@ -1549,7 +1565,10 @@ mod tests { let handler: Arc = Arc::new( TypedHandlerAdapter::::new(Arc::clone(&plugin)), ); - Ok(crate::factory::PluginInstance { plugin, handler }) + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) } } @@ -1565,7 +1584,10 @@ mod tests { let handler: Arc = Arc::new( TypedHandlerAdapter::::new(Arc::clone(&plugin)), ); - Ok(crate::factory::PluginInstance { plugin, handler }) + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) } } @@ -1617,11 +1639,11 @@ plugins: }); // context_table = None (first invocation) - 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"); } @@ -1675,11 +1697,11 @@ plugins: }); // context_table = None (first invocation) - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(!result.allowed); // gate denied before fallback could allow + assert!(!result.continue_processing); // gate denied before fallback could allow } // -- Routing cache tests -- @@ -1932,12 +1954,12 @@ routes: }; // context_table = None (first invocation) - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, ext, None) .await; // Plugin executed (allow plugin returns allowed) - assert!(result.allowed); + assert!(result.continue_processing); // Cache populated assert_eq!(mgr.routing_cache_size(), 1); } @@ -1967,10 +1989,10 @@ plugin_settings: let payload: Box = Box::new(TestPayload { value: "t".into() }); // context_table = None (first invocation) - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(result.allowed); + assert!(result.continue_processing); } // -- End-to-end routing tests -- @@ -2049,18 +2071,18 @@ routes: // 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 + let (r1, _) = mgr .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) .await; - assert!(!r1.allowed); // apl_policy (deny) fires due to pii tag + 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 + let (r2, _) = mgr .invoke_by_name("test_hook", p2, make_meta("tool", "send_email", None, &[]), None) .await; - assert!(r2.allowed); // no deny plugin fires + assert!(r2.continue_processing); // no deny plugin fires } #[tokio::test] @@ -2090,10 +2112,10 @@ plugins: // Even with meta, routing disabled → all plugins fire → denier wins let p: Box = Box::new(TestPayload { value: "t".into() }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", p, make_meta("tool", "anything", None, &[]), None) .await; - assert!(!result.allowed); // denier fires (all plugins active) + assert!(!result.continue_processing); // denier fires (all plugins active) } #[tokio::test] @@ -2131,13 +2153,13 @@ routes: // No meta → all plugins fire (both allower and denier) let p: Box = Box::new(TestPayload { value: "t".into() }); - let result = mgr + 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.allowed || !result.allowed); // both plugins fired + assert!(result.continue_processing || !result.continue_processing); // both plugins fired } #[tokio::test] @@ -2184,17 +2206,17 @@ routes: // get_compensation matches exact route → specific_plugin (deny) let p1: Box = Box::new(TestPayload { value: "t".into() }); - let r1 = mgr + let (r1, _) = mgr .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) .await; - assert!(!r1.allowed); // specific_plugin denies + 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 + let (r2, _) = mgr .invoke_by_name("test_hook", p2, make_meta("tool", "unknown_tool", None, &[]), None) .await; - assert!(r2.allowed); // fallback_plugin allows + assert!(r2.continue_processing); // fallback_plugin allows } #[tokio::test] @@ -2233,20 +2255,20 @@ routes: // Without urgent tag → only identity fires → allowed let p1: Box = Box::new(TestPayload { value: "t".into() }); - let r1 = mgr + let (r1, _) = mgr .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) .await; - assert!(r1.allowed); + 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 + let (r2, _) = mgr .invoke_by_name("test_hook", p2, make_meta("tool", "get_compensation", None, &["urgent"]), None) .await; - assert!(!r2.allowed); + assert!(!r2.continue_processing); } #[tokio::test] @@ -2287,23 +2309,23 @@ routes: // context_table = None (first invocation) // Typed invoke for get_compensation — pii tag activates denier → denied - let r1 = mgr + let (r1, _) = mgr .invoke::( TestPayload { value: "t".into() }, make_meta("tool", "get_compensation", None, &[]), None, ) .await; - assert!(!r1.allowed); + assert!(!r1.continue_processing); // Typed invoke for send_email — no pii tag → only allower → allowed - let r2 = mgr + let (r2, _) = mgr .invoke::( TestPayload { value: "t".into() }, make_meta("tool", "send_email", None, &[]), None, ) .await; - assert!(r2.allowed); + assert!(r2.continue_processing); } } diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index b7ae0fef..fce0466c 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -295,6 +295,49 @@ impl PluginRegistry { } + /// 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 feb85c671623fba99117e30000e8f43df6626cba Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 24 Apr 2026 16:44:46 -0600 Subject: [PATCH 5/8] fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor --- crates/cpex-core/examples/plugin_demo.rs | 18 +------- crates/cpex-core/src/config.rs | 54 +++++++++++++++--------- crates/cpex-core/src/error.rs | 30 +++++++++++++ crates/cpex-core/src/executor.rs | 3 ++ crates/cpex-core/src/manager.rs | 6 +++ crates/cpex-core/src/plugin.rs | 10 ++++- 6 files changed, 83 insertions(+), 38 deletions(-) diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index 20d53844..8e5fb602 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -112,14 +112,7 @@ struct PiiGuard { #[async_trait] impl Plugin for PiiGuard { fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { - println!(" [pii-guard] initialized"); - Ok(()) - } - async fn shutdown(&self) -> Result<(), PluginError> { - println!(" [pii-guard] shutdown"); - Ok(()) - } + // initialize() and shutdown() use defaults — no setup needed } impl HookHandler for PiiGuard { @@ -156,14 +149,7 @@ struct AuditLogger { #[async_trait] impl Plugin for AuditLogger { fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { - println!(" [audit-logger] initialized"); - Ok(()) - } - async fn shutdown(&self) -> Result<(), PluginError> { - println!(" [audit-logger] shutdown"); - Ok(()) - } + // initialize() and shutdown() use defaults — no setup needed } impl HookHandler for AuditLogger { diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 3b83514e..375094e5 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -220,17 +220,17 @@ pub struct RouteEntry { #[serde(default)] pub tool: Option, - /// Match a resource by URI pattern. + /// Match a resource by exact URI, list, or glob. #[serde(default)] - pub resource: Option, + pub resource: Option, - /// Match a prompt by name. + /// Match a prompt by exact name, list, or glob. #[serde(default)] - pub prompt: Option, + pub prompt: Option, - /// Match an LLM by model name. + /// Match an LLM by exact model name, list, or glob. #[serde(default)] - pub llm: Option, + pub llm: Option, /// Operational metadata — tags, scope, properties. #[serde(default)] @@ -553,32 +553,46 @@ fn find_matching_route<'a>( } } "resource" => { - if let Some(pattern) = &route.resource { - if pattern.ends_with('*') { - let prefix = pattern.trim_end_matches('*'); - if !entity_name.starts_with(prefix) { - continue; - } - SPECIFICITY_GLOB - } else if pattern == entity_name { - SPECIFICITY_EXACT_NAME - } else { + 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 route.prompt.as_deref() == Some(entity_name) { - SPECIFICITY_EXACT_NAME + 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 route.llm.as_deref() == Some(entity_name) { - SPECIFICITY_EXACT_NAME + 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; } 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 570a4344..9a6cdcd2 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -195,6 +195,9 @@ impl BackgroundTasks { plugin_name, message: format!("background task panicked: {}", e), source: None, + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, }); } } diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 31be8543..0810bbba 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -451,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, }); } @@ -942,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, }) } 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(()) + } } // --------------------------------------------------------------------------- From e048311628cb3703650f73fe78b8ffeebad13c81 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Wed, 29 Apr 2026 11:16:56 -0600 Subject: [PATCH 6/8] feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor --- Cargo.toml | 2 +- crates/cpex-core/examples/README.md | 37 + .../examples/cmf_capabilities_demo.rs | 339 +++++++ .../examples/cmf_capabilities_demo.yaml | 49 + crates/cpex-core/examples/plugin_demo.rs | 16 +- 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 | 26 + crates/cpex-core/src/cmf/view.rs | 844 ++++++++++++++++++ crates/cpex-core/src/executor.rs | 109 ++- crates/cpex-core/src/extensions/agent.rs | 60 ++ crates/cpex-core/src/extensions/completion.rs | 71 ++ crates/cpex-core/src/extensions/delegation.rs | 161 ++++ crates/cpex-core/src/extensions/filter.rs | 548 ++++++++++++ crates/cpex-core/src/extensions/framework.rs | 38 + crates/cpex-core/src/extensions/guarded.rs | 141 +++ crates/cpex-core/src/extensions/http.rs | 137 +++ crates/cpex-core/src/extensions/llm.rs | 27 + crates/cpex-core/src/extensions/mcp.rs | 115 +++ crates/cpex-core/src/extensions/meta.rs | 33 + crates/cpex-core/src/extensions/mod.rs | 48 + 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 | 512 ++++++++++- crates/cpex-core/src/hooks/trait_def.rs | 6 +- crates/cpex-core/src/lib.rs | 3 + crates/cpex-core/src/manager.rs | 234 ++++- crates/cpex-core/src/plugin.rs | 2 +- crates/cpex-core/src/registry.rs | 8 +- crates/cpex-sdk/src/lib.rs | 13 +- 36 files changed, 5283 insertions(+), 110 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/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/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..ef1dd32a --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -0,0 +1,339 @@ +// 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::{ + Guarded, 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 { + let tool_name = payload.message.get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + // Check security labels (capability: read_labels) + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + println!(" [identity-checker] Security labels visible: {:?}", labels); + println!(" [identity-checker] Classification: {:?}", security.classification); + + // Check subject (capability: read_subject, read_roles) + if let Some(ref subject) = security.subject { + println!(" [identity-checker] Subject: {:?}", subject.id); + let roles: Vec<&String> = subject.roles.iter().collect(); + println!(" [identity-checker] Roles: {:?}", roles); + + 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), + )); + } + } else { + println!(" [identity-checker] No subject visible (missing capability)"); + } + } else { + println!(" [identity-checker] No security extension visible"); + } + + // Check HTTP (should NOT be visible — no read_headers capability) + if extensions.http.is_some() { + println!(" [identity-checker] WARNING: HTTP visible (unexpected!)"); + } else { + println!(" [identity-checker] HTTP: not visible (no read_headers capability)"); + } + + println!(" [identity-checker] ALLOWED: tool '{}' for authorized user", tool_name); + 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.read().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 tool_name = payload.message.get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + print!(" [audit-logger] AUDIT: tool='{}' ", 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.read().get_header("X-Request-ID") { + print!("request_id='{}' ", req_id); + } + } + + if let Some(ref meta) = extensions.meta { + print!("entity='{:?}' ", meta.entity_name); + } + + 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))), + ], + }) + } +} + +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))), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// 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: "2.0".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(security), + http: Some(Guarded::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() + }; + + // --- Invoke --- + println!("--- Invoking cmf.tool_pre_invoke ---\n"); + let boxed: Box = Box::new(payload); + let (result, bg) = mgr.invoke_by_name("cmf.tool_pre_invoke", boxed, ext, None).await; + + println!(); + if result.continue_processing { + println!("Result: ALLOWED"); + if let Some(ref modified_ext) = result.modified_extensions { + if let Some(ref sec) = modified_ext.security { + let labels: Vec<&String> = sec.labels.iter().collect(); + println!("Final labels: {:?}", labels); + } + if let Some(ref http) = modified_ext.http { + println!("Final headers: {:?}", http.read().headers); + } + } + } else { + println!("Result: DENIED — {}", result.violation.as_ref().unwrap().reason); + } + + 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..e94576fe --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.yaml @@ -0,0 +1,49 @@ +# CMF Capabilities Demo Configuration +# +# Three plugins with different capabilities see different views +# of the same extensions. Demonstrates capability-gated access. + +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] + 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] + 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/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..5719be46 --- /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 { + "2.0".to_string() +} + +impl Message { + /// Create a simple text message. + pub fn text(role: Role, text: impl Into) -> Self { + Self { + schema_version: "2.0".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..a8d04952 --- /dev/null +++ b/crates/cpex-core/src/cmf/mod.rs @@ -0,0 +1,26 @@ +// 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. + +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..7f004683 --- /dev/null +++ b/crates/cpex-core/src/cmf/view.rs @@ -0,0 +1,844 @@ +// 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.read().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 { + let mut result = serde_json::Map::new(); + + // Core fields + result.insert("kind".into(), serde_json::json!(self.kind)); + result.insert("role".into(), serde_json::json!(self.role)); + result.insert("is_pre".into(), serde_json::json!(self.is_pre())); + result.insert("is_post".into(), serde_json::json!(self.is_post())); + result.insert("action".into(), serde_json::json!(self.action())); + + if let Some(hook) = self.hook { + result.insert("hook".into(), serde_json::json!(hook)); + } + + if let Some(uri) = self.uri() { + result.insert("uri".into(), serde_json::json!(uri)); + } + + if let Some(name) = self.name() { + result.insert("name".into(), serde_json::json!(name)); + } + + // Content + if include_content { + if let Some(text) = self.content() { + result.insert("size_bytes".into(), serde_json::json!(text.len())); + result.insert("content".into(), serde_json::json!(text)); + } + } + + if let Some(mime) = self.mime_type() { + result.insert("mime_type".into(), serde_json::json!(mime)); + } + + // Arguments + if let Some(args) = self.args() { + result.insert("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("id".into(), serde_json::json!(id)); + } + if let Some(ref st) = subject.subject_type { + sub_map.insert("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("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("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("teams".into(), serde_json::json!(teams)); + } + if !sub_map.is_empty() { + ext_map.insert("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("labels".into(), serde_json::json!(labels)); + } + } + + // Environment + if let Some(ref req) = ext.request { + if let Some(ref env) = req.environment { + ext_map.insert("environment".into(), serde_json::json!(env)); + } + } + + // Headers (strip sensitive) + if let Some(ref http) = ext.http { + let headers = &http.read().headers; + let safe: std::collections::HashMap<&String, &String> = headers + .iter() + .filter(|(k, _)| { + !Self::SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) + }) + .collect(); + if !safe.is_empty() { + ext_map.insert("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("input".into(), serde_json::json!(input)); + } + if let Some(ref sid) = agent.session_id { + agent_map.insert("session_id".into(), serde_json::json!(sid)); + } + if let Some(ref cid) = agent.conversation_id { + agent_map.insert("conversation_id".into(), serde_json::json!(cid)); + } + if let Some(turn) = agent.turn { + agent_map.insert("turn".into(), serde_json::json!(turn)); + } + if let Some(ref aid) = agent.agent_id { + agent_map.insert("agent_id".into(), serde_json::json!(aid)); + } + if let Some(ref paid) = agent.parent_agent_id { + agent_map.insert("parent_agent_id".into(), serde_json::json!(paid)); + } + if !agent_map.is_empty() { + ext_map.insert("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("entity_type".into(), serde_json::json!(et)); + } + if let Some(ref en) = meta.entity_name { + meta_map.insert("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("tags".into(), serde_json::json!(tags)); + } + if !meta_map.is_empty() { + ext_map.insert("meta".into(), serde_json::Value::Object(meta_map)); + } + } + + if !ext_map.is_empty() { + result.insert("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 { + serde_json::json!({ + "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 crate::extensions::{SecurityExtension, Guarded, 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(security), + http: Some(Guarded::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, Guarded, 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(security), + http: Some(Guarded::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..ee678016 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; @@ -406,8 +428,32 @@ impl Executor { *payload = mp; } if let Some(me) = erased.modified_extensions { - // TODO: Merge with tier validation (Phase 3) - *extensions = me; + // Validate tier constraints before accepting + if !extensions.validate_immutable(&me) { + 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) = me.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 = me; + } + } else { + *extensions = me; + } + } else { + *extensions = me; + } } } @@ -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), @@ -817,14 +891,19 @@ mod tests { #[test] fn test_erase_result_modify_extensions() { - let mut ext = Extensions::default(); - ext.labels.insert("PII".into()); + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("PII"); + let ext = Extensions { + security: Some(security), + ..Default::default() + }; 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")); + 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/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..17b2d91e --- /dev/null +++ b/crates/cpex-core/src/extensions/filter.rs @@ -0,0 +1,548 @@ +// 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 crate::hooks::payload::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(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::hooks::payload::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(security), + http: Some(crate::extensions::Guarded::new(http)), + agent: Some(std::sync::Arc::new(super::super::AgentExtension { + agent_id: Some("agent-1".into()), + ..Default::default() + })), + delegation: Some(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([("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.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().read().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.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.unwrap(); + let subject = sec.subject.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.unwrap(); + let subject = sec.subject.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.unwrap(); + assert!(sec.has_label("PII")); + let subject = sec.subject.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..e3c46ec0 --- /dev/null +++ b/crates/cpex-core/src/extensions/http.rs @@ -0,0 +1,137 @@ +// Location: ./crates/cpex-core/src/extensions/http.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HttpExtension — HTTP headers. +// Mirrors cpex/framework/extensions/http.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// HTTP-related extensions. +/// +/// Carries HTTP headers. Capability-gated: requires `read_headers` +/// to see, `write_headers` to modify. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HttpExtension { + /// HTTP headers as key-value pairs. + #[serde(default)] + pub headers: HashMap, +} + +impl HttpExtension { + /// Set a header (overwrites if exists). + pub fn set_header(&mut self, name: impl Into, value: impl Into) { + self.headers.insert(name.into(), value.into()); + } + + /// Get a header value (case-insensitive lookup). + pub fn get_header(&self, name: &str) -> Option<&str> { + let lower = name.to_lowercase(); + self.headers + .iter() + .find(|(k, _)| k.to_lowercase() == lower) + .map(|(_, v)| v.as_str()) + } + + /// Check if a header exists (case-insensitive). + pub fn has_header(&self, name: &str) -> bool { + self.get_header(name).is_some() + } + + /// Add header only if it doesn't exist. Returns true if added. + pub fn add_header(&mut self, name: impl Into, value: impl Into) -> bool { + let name = name.into(); + if self.has_header(&name) { + return false; + } + self.headers.insert(name, value.into()); + true + } + + /// Remove a header by name. Returns the removed value. + pub fn remove_header(&mut self, name: &str) -> Option { + let lower = name.to_lowercase(); + let key = self + .headers + .keys() + .find(|k| k.to_lowercase() == lower) + .cloned(); + key.and_then(|k| self.headers.remove(&k)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_and_get_header() { + let mut http = HttpExtension::default(); + http.set_header("Content-Type", "application/json"); + assert_eq!(http.get_header("Content-Type"), Some("application/json")); + } + + #[test] + fn test_get_header_case_insensitive() { + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer tok"); + assert_eq!(http.get_header("authorization"), Some("Bearer tok")); + assert_eq!(http.get_header("AUTHORIZATION"), Some("Bearer tok")); + } + + #[test] + fn test_has_header() { + let mut http = HttpExtension::default(); + assert!(!http.has_header("X-Custom")); + http.set_header("X-Custom", "value"); + assert!(http.has_header("X-Custom")); + assert!(http.has_header("x-custom")); // case-insensitive + } + + #[test] + fn test_add_header_only_if_absent() { + let mut http = HttpExtension::default(); + assert!(http.add_header("X-New", "first")); + assert!(!http.add_header("X-New", "second")); // already exists + assert_eq!(http.get_header("X-New"), Some("first")); + } + + #[test] + fn test_set_header_overwrites() { + let mut http = HttpExtension::default(); + http.set_header("X-Val", "old"); + http.set_header("X-Val", "new"); + assert_eq!(http.get_header("X-Val"), Some("new")); + } + + #[test] + fn test_remove_header() { + let mut http = HttpExtension::default(); + http.set_header("X-Remove", "value"); + let removed = http.remove_header("x-remove"); // case-insensitive + assert_eq!(removed, Some("value".to_string())); + assert!(!http.has_header("X-Remove")); + } + + #[test] + fn test_remove_nonexistent_header() { + let mut http = HttpExtension::default(); + assert!(http.remove_header("X-Missing").is_none()); + } + + #[test] + fn test_serde_roundtrip() { + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer tok"); + http.set_header("X-Request-ID", "req-123"); + + let json = serde_json::to_string(&http).unwrap(); + let deserialized: HttpExtension = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.get_header("Authorization"), Some("Bearer tok")); + assert_eq!(deserialized.get_header("X-Request-ID"), Some("req-123")); + } +} 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..99f9a7a2 --- /dev/null +++ b/crates/cpex-core/src/extensions/meta.rs @@ -0,0 +1,33 @@ +// 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 for route resolution, tags for +/// policy group inheritance, scope for host-defined grouping, +/// and arbitrary properties. +/// +/// Immutable — set by the host before invoking the hook. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MetaExtension { + /// Operational tags — drive policy group inheritance. + #[serde(default)] + pub tags: HashSet, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + #[serde(default, skip_serializing_if = "Option::is_none")] + 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..725cc17a --- /dev/null +++ b/crates/cpex-core/src/extensions/mod.rs @@ -0,0 +1,48 @@ +// 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 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 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 http::HttpExtension; +pub use llm::LLMExtension; +pub use mcp::{MCPExtension, PromptMetadata, ResourceMetadata, ToolMetadata}; +pub use meta::MetaExtension; +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 guarded::{Guarded, WriteToken}; +pub use monotonic::{DeclassifierToken, MonotonicSet}; +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..ebb6a330 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -23,40 +23,22 @@ use std::any::Any; use std::collections::HashMap; use std::fmt; +use std::sync::Arc; use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- -// Extensions (stub — fleshed out in Phase 3 with full CMF types) +// Extensions — full typed container // --------------------------------------------------------------------------- -/// 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, -} +// Re-export the MetaExtension with entity routing fields here +// since it has additional fields beyond the extensions::meta version +// (entity_type, entity_name for routing). +pub use crate::extensions::{ + AgentExtension, CompletionExtension, DelegationExtension, FrameworkExtension, Guarded, + HttpExtension, LLMExtension, MCPExtension, ProvenanceExtension, RequestExtension, + SecurityExtension, WriteToken, +}; /// Host-provided operational metadata about the entity being processed. /// @@ -66,8 +48,6 @@ pub struct Extensions { /// /// 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". @@ -94,24 +74,190 @@ pub struct MetaExtension { pub properties: HashMap, } -/// Capability-filtered view of Extensions for a specific plugin. +/// Typed container for all message extensions. /// -/// 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. +/// 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. /// -/// 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, +/// Mirrors Python's `cpex.framework.extensions.Extensions`. +/// 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. +/// +/// **Tier enforcement:** +/// - **Immutable** (`Arc`) — shared by reference, zero-copy clone. +/// No `&mut` path exists. Plugins receive `&T` via auto-deref. +/// - **Monotonic** (`MonotonicSet`, append-only chain) — only `insert()` +/// / `append()` methods exposed. No `remove()` at compile time. +/// - **Guarded** (`Guarded`) — read via `.read()`, write via +/// `.write(token)` requiring a `WriteToken` from the framework. +/// - **Mutable** — standard types, freely modifiable. +/// +/// **Capability gating:** `filter_extensions()` builds a filtered +/// copy with `None` for slots the plugin can't see. Write tokens +/// are only set when the plugin declared the write capability. +/// +/// Mirrors Python's `cpex.framework.extensions.Extensions`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Extensions { + /// Execution environment and request tracing (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option>, + + /// Agent execution context — session, conversation, lineage (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option>, - /// Security labels (visible with `read_labels` capability). - pub labels: Option>, + /// HTTP headers (guarded — requires WriteToken for mutation). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option>, - /// Custom extensions (always visible). + /// Security — labels (monotonic add-only via MonotonicSet), + /// classification, subject, objects, data policies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security: Option, + + /// Delegation chain (monotonic — append-only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: Option, + + /// MCP entity metadata — tool, resource, or prompt info (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp: Option>, + + /// LLM completion information (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion: Option>, + + /// Origin and message threading (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option>, + + /// Model identity and capabilities (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option>, + + /// Agentic framework context (immutable, Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub framework: Option>, + + /// Host-provided operational metadata — tags, scope, properties (immutable, Arc). + /// Also carries entity_type and entity_name for route resolution. + #[serde(default)] + pub meta: Option>, + + /// Custom extensions (mutable — no restrictions). + #[serde(default, skip_serializing_if = "Option::is_none")] pub custom: Option>, + + /// Write token for HTTP headers. Present only if the plugin + /// declared `write_headers` capability. Required for `http.write()`. + #[serde(skip)] + pub http_write_token: Option, + + /// Write token for label append. Present only if the plugin + /// declared `append_labels` capability. + #[serde(skip)] + pub labels_write_token: Option, + + /// Write token for delegation append. Present only if the plugin + /// declared `append_delegation` capability. + #[serde(skip)] + pub delegation_write_token: Option, +} + +impl Clone for Extensions { + /// Clone data fields. Immutable slots are Arc refcount bumps (~1ns). + /// Mutable/monotonic slots are deep cloned. Write tokens are NOT + /// cloned — they only exist on COW copies created by `cow_copy()`. + 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 clone for modification. + /// + /// Immutable slots share the same `Arc` (refcount bump, ~1ns). + /// Mutable/monotonic slots are deep cloned. + /// Write tokens are carried over from the original — only tokens + /// the executor set (based on trusted capabilities) are propagated. + /// No capability parameter needed — the plugin can't forge tokens + /// because it only has `&self` (immutable) access to the original. + /// + /// # Usage + /// + /// ```ignore + /// // In a plugin handler: + /// fn handle(&self, payload: &P, ext: &Extensions, ctx: &mut PluginContext) -> PluginResult

{ + /// let mut my_ext = ext.cow_copy(); + /// // Modify — only works if the executor gave us the write token + /// if let Some(ref token) = my_ext.http_write_token { + /// my_ext.http.as_mut().unwrap().write(token).set_header("X-Foo", "bar"); + /// } + /// PluginResult::modify_extensions(my_ext) + /// } + /// ``` + pub fn cow_copy(&self) -> Self { + let mut copy = self.clone(); // data cloned, tokens dropped + + // Carry over write tokens that the executor set on the original. + // Only tokens that already exist are propagated — can't escalate. + if self.http_write_token.is_some() { + copy.http_write_token = Some(WriteToken::new()); + } + if self.labels_write_token.is_some() { + copy.labels_write_token = Some(WriteToken::new()); + } + if self.delegation_write_token.is_some() { + copy.delegation_write_token = Some(WriteToken::new()); + } + + copy + } + + /// Validate that immutable slots were not tampered with. + /// + /// Uses `Arc::ptr_eq` to confirm immutable slots still point to + /// the same data. Called by the executor after a plugin returns + /// modified extensions. + pub fn validate_immutable(&self, modified: &Extensions) -> 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) + } } // --------------------------------------------------------------------------- @@ -139,7 +285,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 +362,281 @@ macro_rules! impl_plugin_payload { } }; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::{ + DelegationExtension, Guarded, 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(security), + http: Some(Guarded::new(http)), + delegation: Some(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().read().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(security), + http: Some(Guarded::new(http)), + delegation: Some(DelegationExtension::default()), + custom: Some([("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().read().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.read().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/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index a437c955..be5f8a45 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; } 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..c25bab5c 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -859,7 +859,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 +900,7 @@ mod tests { fn handle( &self, _payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::allow() @@ -923,7 +923,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 +938,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { Err(PluginError::Execution { @@ -1240,7 +1240,7 @@ mod tests { fn handle( &self, payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::modify_payload(TestPayload { @@ -1259,7 +1259,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 +1308,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 +1393,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 +1440,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 +1459,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 +1511,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 +1742,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 +1757,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 +1799,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 +1811,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 +1850,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 +1893,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 +1951,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 +2015,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 +2334,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(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(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 9d08049c6ed6e3a726162e90eed3f2393ab1c69f Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Wed, 29 Apr 2026 16:49:49 -0600 Subject: [PATCH 7/8] feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor --- .../examples/cmf_capabilities_demo.rs | 204 +++++++++++++----- .../examples/cmf_capabilities_demo.yaml | 7 +- crates/cpex-core/src/cmf/constants.rs | 65 ++++++ crates/cpex-core/src/cmf/message.rs | 4 +- crates/cpex-core/src/cmf/mod.rs | 74 +++++++ crates/cpex-core/src/cmf/view.rs | 78 +++---- crates/cpex-core/src/extensions/http.rs | 187 ++++++++++------ crates/cpex-core/src/manager.rs | 137 ++++++++++++ 8 files changed, 598 insertions(+), 158 deletions(-) create mode 100644 crates/cpex-core/src/cmf/constants.rs diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs index ef1dd32a..52b4c064 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -48,44 +48,56 @@ impl HookHandler for IdentityChecker { extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - let tool_name = payload.message.get_tool_calls() - .first() - .map(|tc| tc.name.as_str()) - .unwrap_or("unknown"); - - // Check security labels (capability: read_labels) - if let Some(ref security) = extensions.security { - let labels: Vec<&String> = security.labels.iter().collect(); - println!(" [identity-checker] Security labels visible: {:?}", labels); - println!(" [identity-checker] Classification: {:?}", security.classification); - - // Check subject (capability: read_subject, read_roles) - if let Some(ref subject) = security.subject { - println!(" [identity-checker] Subject: {:?}", subject.id); - let roles: Vec<&String> = subject.roles.iter().collect(); - println!(" [identity-checker] Roles: {:?}", roles); - - 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), - )); + // 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); } - } else { - println!(" [identity-checker] No subject visible (missing capability)"); } + println!(" [identity-checker] POST-INVOKE ALLOWED"); } else { - println!(" [identity-checker] No security extension visible"); - } + // 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), + )); + } + } + } - // Check HTTP (should NOT be visible — no read_headers capability) - if extensions.http.is_some() { - println!(" [identity-checker] WARNING: HTTP visible (unexpected!)"); - } else { - println!(" [identity-checker] HTTP: not visible (no read_headers capability)"); + 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"); } - println!(" [identity-checker] ALLOWED: tool '{}' for authorized user", tool_name); PluginResult::allow() } } @@ -114,7 +126,7 @@ 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.read().headers); + println!(" [header-injector] HTTP headers visible: {:?}", http.read().request_headers); } // Can NOT see security subject (no read_subject) @@ -167,12 +179,22 @@ impl HookHandler for AuditLogger { extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - let tool_name = payload.message.get_tool_calls() - .first() - .map(|tc| tc.name.as_str()) - .unwrap_or("unknown"); + 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='{}' ", tool_name); + print!(" [audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name); if let Some(ref security) = extensions.security { let labels: Vec<&String> = security.labels.iter().collect(); @@ -185,8 +207,12 @@ impl HookHandler for AuditLogger { } } - if let Some(ref meta) = extensions.meta { - print!("entity='{:?}' ", meta.entity_name); + 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!(); @@ -205,7 +231,8 @@ impl PluginFactory for IdentityCheckerFactory { Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("cmf.tool_pre_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))), ], }) } @@ -231,7 +258,8 @@ impl PluginFactory for AuditLoggerFactory { Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("cmf.tool_pre_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))), ], }) } @@ -262,7 +290,7 @@ async fn main() { // --- Build CMF Message --- let payload = MessagePayload { message: Message { - schema_version: "2.0".into(), + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), role: Role::Assistant, content: vec![ ContentPart::Text { text: "Looking up compensation.".into() }, @@ -313,27 +341,95 @@ async fn main() { ..Default::default() }; - // --- Invoke --- - println!("--- Invoking cmf.tool_pre_invoke ---\n"); - let boxed: Box = Box::new(payload); - let (result, bg) = mgr.invoke_by_name("cmf.tool_pre_invoke", boxed, ext, None).await; + // --- 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 result.continue_processing { - println!("Result: ALLOWED"); - if let Some(ref modified_ext) = result.modified_extensions { + 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!("Final labels: {:?}", labels); + println!(" Labels after pre-invoke: {:?}", labels); } if let Some(ref http) = modified_ext.http { - println!("Final headers: {:?}", http.read().headers); + println!(" Headers after pre-invoke: {:?}", http.read().request_headers); } } } else { - println!("Result: DENIED — {}", 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; } - 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(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 index e94576fe..ace0f5cb 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.yaml +++ b/crates/cpex-core/examples/cmf_capabilities_demo.yaml @@ -1,7 +1,8 @@ # CMF Capabilities Demo Configuration # # Three plugins with different capabilities see different views -# of the same extensions. Demonstrates capability-gated access. +# of the same extensions. Demonstrates capability-gated access +# across pre-invoke and post-invoke hooks. plugin_settings: routing_enabled: true @@ -14,7 +15,7 @@ global: plugins: - name: identity-checker kind: builtin/identity-checker - hooks: [cmf.tool_pre_invoke] + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] mode: sequential priority: 10 on_error: fail @@ -36,7 +37,7 @@ plugins: - name: audit-logger kind: builtin/audit-logger - hooks: [cmf.tool_pre_invoke] + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] mode: audit priority: 100 on_error: ignore 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/message.rs b/crates/cpex-core/src/cmf/message.rs index 5719be46..a8e700d5 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -52,14 +52,14 @@ pub struct Message { } fn default_schema_version() -> String { - "2.0".to_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: "2.0".to_string(), + schema_version: super::constants::SCHEMA_VERSION.to_string(), role, content: vec![ContentPart::Text { text: text.into(), diff --git a/crates/cpex-core/src/cmf/mod.rs b/crates/cpex-core/src/cmf/mod.rs index a8d04952..11ae76a4 100644 --- a/crates/cpex-core/src/cmf/mod.rs +++ b/crates/cpex-core/src/cmf/mod.rs @@ -13,7 +13,81 @@ // 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; diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs index 7f004683..d105315f 100644 --- a/crates/cpex-core/src/cmf/view.rs +++ b/crates/cpex-core/src/cmf/view.rs @@ -346,42 +346,44 @@ impl<'a> MessageView<'a> { include_content: bool, include_context: bool, ) -> serde_json::Value { + use super::constants::*; + let mut result = serde_json::Map::new(); // Core fields - result.insert("kind".into(), serde_json::json!(self.kind)); - result.insert("role".into(), serde_json::json!(self.role)); - result.insert("is_pre".into(), serde_json::json!(self.is_pre())); - result.insert("is_post".into(), serde_json::json!(self.is_post())); - result.insert("action".into(), serde_json::json!(self.action())); + 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("hook".into(), serde_json::json!(hook)); + result.insert(FIELD_HOOK.into(), serde_json::json!(hook)); } if let Some(uri) = self.uri() { - result.insert("uri".into(), serde_json::json!(uri)); + result.insert(FIELD_URI.into(), serde_json::json!(uri)); } if let Some(name) = self.name() { - result.insert("name".into(), serde_json::json!(name)); + result.insert(FIELD_NAME.into(), serde_json::json!(name)); } // Content if include_content { if let Some(text) = self.content() { - result.insert("size_bytes".into(), serde_json::json!(text.len())); - result.insert("content".into(), serde_json::json!(text)); + 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("mime_type".into(), serde_json::json!(mime)); + result.insert(FIELD_MIME_TYPE.into(), serde_json::json!(mime)); } // Arguments if let Some(args) = self.args() { - result.insert("arguments".into(), serde_json::json!(args)); + result.insert(FIELD_ARGUMENTS.into(), serde_json::json!(args)); } // Extensions context @@ -394,28 +396,28 @@ impl<'a> MessageView<'a> { 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("id".into(), serde_json::json!(id)); + sub_map.insert(FIELD_ID.into(), serde_json::json!(id)); } if let Some(ref st) = subject.subject_type { - sub_map.insert("type".into(), serde_json::json!(st)); + 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("roles".into(), serde_json::json!(roles)); + 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("permissions".into(), serde_json::json!(perms)); + 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("teams".into(), serde_json::json!(teams)); + sub_map.insert(FIELD_TEAMS.into(), serde_json::json!(teams)); } if !sub_map.is_empty() { - ext_map.insert("subject".into(), serde_json::Value::Object(sub_map)); + ext_map.insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); } } @@ -423,28 +425,29 @@ impl<'a> MessageView<'a> { if !sec.labels.is_empty() { let mut labels: Vec<&String> = sec.labels.iter().collect(); labels.sort(); - ext_map.insert("labels".into(), serde_json::json!(labels)); + 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("environment".into(), serde_json::json!(env)); + ext_map.insert(FIELD_ENVIRONMENT.into(), serde_json::json!(env)); } } - // Headers (strip sensitive) + // Request headers (strip sensitive) if let Some(ref http) = ext.http { - let headers = &http.read().headers; - let safe: std::collections::HashMap<&String, &String> = headers + let http_ref = http.read(); + let safe: std::collections::HashMap<&String, &String> = http_ref + .request_headers .iter() .filter(|(k, _)| { !Self::SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) }) .collect(); if !safe.is_empty() { - ext_map.insert("headers".into(), serde_json::json!(safe)); + ext_map.insert(FIELD_HEADERS.into(), serde_json::json!(safe)); } } @@ -452,25 +455,25 @@ impl<'a> MessageView<'a> { 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("input".into(), serde_json::json!(input)); + agent_map.insert(FIELD_INPUT.into(), serde_json::json!(input)); } if let Some(ref sid) = agent.session_id { - agent_map.insert("session_id".into(), serde_json::json!(sid)); + agent_map.insert(FIELD_SESSION_ID.into(), serde_json::json!(sid)); } if let Some(ref cid) = agent.conversation_id { - agent_map.insert("conversation_id".into(), serde_json::json!(cid)); + agent_map.insert(FIELD_CONVERSATION_ID.into(), serde_json::json!(cid)); } if let Some(turn) = agent.turn { - agent_map.insert("turn".into(), serde_json::json!(turn)); + agent_map.insert(FIELD_TURN.into(), serde_json::json!(turn)); } if let Some(ref aid) = agent.agent_id { - agent_map.insert("agent_id".into(), serde_json::json!(aid)); + agent_map.insert(FIELD_AGENT_ID.into(), serde_json::json!(aid)); } if let Some(ref paid) = agent.parent_agent_id { - agent_map.insert("parent_agent_id".into(), serde_json::json!(paid)); + agent_map.insert(FIELD_PARENT_AGENT_ID.into(), serde_json::json!(paid)); } if !agent_map.is_empty() { - ext_map.insert("agent".into(), serde_json::Value::Object(agent_map)); + ext_map.insert(FIELD_AGENT.into(), serde_json::Value::Object(agent_map)); } } @@ -478,23 +481,23 @@ impl<'a> MessageView<'a> { 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("entity_type".into(), serde_json::json!(et)); + meta_map.insert(FIELD_ENTITY_TYPE.into(), serde_json::json!(et)); } if let Some(ref en) = meta.entity_name { - meta_map.insert("entity_name".into(), serde_json::json!(en)); + 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("tags".into(), serde_json::json!(tags)); + meta_map.insert(FIELD_TAGS.into(), serde_json::json!(tags)); } if !meta_map.is_empty() { - ext_map.insert("meta".into(), serde_json::Value::Object(meta_map)); + ext_map.insert(FIELD_META.into(), serde_json::Value::Object(meta_map)); } } if !ext_map.is_empty() { - result.insert("extensions".into(), serde_json::Value::Object(ext_map)); + result.insert(FIELD_EXTENSIONS.into(), serde_json::Value::Object(ext_map)); } } } @@ -507,8 +510,9 @@ impl<'a> MessageView<'a> { /// 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!({ - "input": self.to_dict(include_content, true) + FIELD_OPA_INPUT: self.to_dict(include_content, true) }) } } diff --git a/crates/cpex-core/src/extensions/http.rs b/crates/cpex-core/src/extensions/http.rs index e3c46ec0..bfd52903 100644 --- a/crates/cpex-core/src/extensions/http.rs +++ b/crates/cpex-core/src/extensions/http.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// HttpExtension — HTTP headers. +// HttpExtension — HTTP request and response headers. // Mirrors cpex/framework/extensions/http.py. use std::collections::HashMap; @@ -12,55 +12,109 @@ use serde::{Deserialize, Serialize}; /// HTTP-related extensions. /// -/// Carries HTTP headers. Capability-gated: requires `read_headers` -/// to see, `write_headers` to modify. +/// 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 headers as key-value pairs. + /// HTTP request headers (inbound from caller). + #[serde(default)] + pub request_headers: HashMap, + + /// HTTP response headers (from upstream, populated post-invoke). #[serde(default)] - pub headers: HashMap, + pub response_headers: HashMap, } impl HttpExtension { - /// Set a header (overwrites if exists). - pub fn set_header(&mut self, name: impl Into, value: impl Into) { - self.headers.insert(name.into(), value.into()); + // -- 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 header value (case-insensitive lookup). - pub fn get_header(&self, name: &str) -> Option<&str> { - let lower = name.to_lowercase(); - self.headers - .iter() - .find(|(k, _)| k.to_lowercase() == lower) - .map(|(_, v)| v.as_str()) + /// 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 header exists (case-insensitive). - pub fn has_header(&self, name: &str) -> bool { - self.get_header(name).is_some() + /// 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 header only if it doesn't exist. Returns true if added. - pub fn add_header(&mut self, name: impl Into, value: impl Into) -> bool { + /// 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_header(&name) { + if self.has_request_header(&name) { return false; } - self.headers.insert(name, value.into()); + self.request_headers.insert(name, value.into()); true } - /// Remove a header by name. Returns the removed value. - pub fn remove_header(&mut self, name: &str) -> Option { - let lower = name.to_lowercase(); - let key = self - .headers - .keys() - .find(|k| k.to_lowercase() == lower) - .cloned(); - key.and_then(|k| self.headers.remove(&k)) + /// 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)] @@ -68,70 +122,79 @@ mod tests { use super::*; #[test] - fn test_set_and_get_header() { + fn test_request_header_set_and_get() { let mut http = HttpExtension::default(); - http.set_header("Content-Type", "application/json"); - assert_eq!(http.get_header("Content-Type"), Some("application/json")); + http.set_request_header("Content-Type", "application/json"); + assert_eq!(http.get_request_header("Content-Type"), Some("application/json")); } #[test] - fn test_get_header_case_insensitive() { + fn test_request_header_case_insensitive() { let mut http = HttpExtension::default(); - http.set_header("Authorization", "Bearer tok"); - assert_eq!(http.get_header("authorization"), Some("Bearer tok")); - assert_eq!(http.get_header("AUTHORIZATION"), Some("Bearer tok")); + 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_has_header() { + fn test_response_header_set_and_get() { let mut http = HttpExtension::default(); - assert!(!http.has_header("X-Custom")); - http.set_header("X-Custom", "value"); - assert!(http.has_header("X-Custom")); - assert!(http.has_header("x-custom")); // case-insensitive + 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_add_header_only_if_absent() { + fn test_request_and_response_independent() { let mut http = HttpExtension::default(); - assert!(http.add_header("X-New", "first")); - assert!(!http.add_header("X-New", "second")); // already exists - assert_eq!(http.get_header("X-New"), Some("first")); + 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_set_header_overwrites() { + fn test_convenience_aliases_default_to_request() { let mut http = HttpExtension::default(); - http.set_header("X-Val", "old"); - http.set_header("X-Val", "new"); - assert_eq!(http.get_header("X-Val"), Some("new")); + 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_remove_header() { + fn test_add_request_header_only_if_absent() { let mut http = HttpExtension::default(); - http.set_header("X-Remove", "value"); - let removed = http.remove_header("x-remove"); // case-insensitive - assert_eq!(removed, Some("value".to_string())); - assert!(!http.has_header("X-Remove")); + 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_nonexistent_header() { + fn test_remove_request_header() { let mut http = HttpExtension::default(); - assert!(http.remove_header("X-Missing").is_none()); + 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_header("Authorization", "Bearer tok"); - http.set_header("X-Request-ID", "req-123"); + 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_header("Authorization"), Some("Bearer tok")); - assert_eq!(deserialized.get_header("X-Request-ID"), Some("req-123")); + 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/manager.rs b/crates/cpex-core/src/manager.rs index c25bab5c..f16b0a81 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 // ----------------------------------------------------------------------- @@ -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(); From ac6881bf5eb997a2a9e7cbb72597ad5d5223f503 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Wed, 29 Apr 2026 20:29:01 -0600 Subject: [PATCH 8/8] feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor --- .../examples/cmf_capabilities_demo.rs | 14 +- crates/cpex-core/src/cmf/view.rs | 18 +- crates/cpex-core/src/executor.rs | 19 +- crates/cpex-core/src/extensions/container.rs | 532 ++++++++++++++++++ crates/cpex-core/src/extensions/filter.rs | 35 +- crates/cpex-core/src/extensions/meta.rs | 22 +- crates/cpex-core/src/extensions/mod.rs | 8 +- crates/cpex-core/src/hooks/payload.rs | 516 +---------------- crates/cpex-core/src/hooks/trait_def.rs | 16 +- crates/cpex-core/src/manager.rs | 4 +- 10 files changed, 615 insertions(+), 569 deletions(-) create mode 100644 crates/cpex-core/src/extensions/container.rs diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs index 52b4c064..1257f03d 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -17,7 +17,7 @@ use cpex_core::cmf::{ContentPart, CmfHook, Message, MessagePayload, Role, ToolCa use cpex_core::context::PluginContext; use cpex_core::error::{PluginError, PluginViolation}; use cpex_core::extensions::{ - Guarded, HttpExtension, RequestExtension, SecurityExtension, + HttpExtension, RequestExtension, SecurityExtension, }; use cpex_core::factory::{PluginFactory, PluginInstance}; use cpex_core::hooks::adapter::TypedHandlerAdapter; @@ -126,7 +126,7 @@ 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.read().request_headers); + println!(" [header-injector] HTTP headers visible: {:?}", http.request_headers); } // Can NOT see security subject (no read_subject) @@ -202,7 +202,7 @@ impl HookHandler for AuditLogger { } if let Some(ref http) = extensions.http { - if let Some(req_id) = http.read().get_header("X-Request-ID") { + if let Some(req_id) = http.get_header("X-Request-ID") { print!("request_id='{}' ", req_id); } } @@ -330,8 +330,8 @@ async fn main() { request_id: Some("req-abc-123".into()), ..Default::default() })), - security: Some(security), - http: Some(Guarded::new(http)), + 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()), @@ -362,7 +362,7 @@ async fn main() { println!(" Labels after pre-invoke: {:?}", labels); } if let Some(ref http) = modified_ext.http { - println!(" Headers after pre-invoke: {:?}", http.read().request_headers); + println!(" Headers after pre-invoke: {:?}", http.request_headers); } } } else { @@ -405,7 +405,7 @@ async fn main() { let mut security = SecurityExtension::default(); security.add_label("PII"); Extensions { - security: Some(security), + security: Some(Arc::new(security)), meta: Some(Arc::new(MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs index d105315f..3407b472 100644 --- a/crates/cpex-core/src/cmf/view.rs +++ b/crates/cpex-core/src/cmf/view.rs @@ -328,7 +328,7 @@ impl<'a> MessageView<'a> { pub fn get_header(&self, name: &str) -> Option<&str> { self.extensions .and_then(|e| e.http.as_ref()) - .and_then(|h| h.read().get_header(name)) + .and_then(|h| h.get_header(name)) } // -- Serialization -- @@ -438,8 +438,7 @@ impl<'a> MessageView<'a> { // Request headers (strip sensitive) if let Some(ref http) = ext.http { - let http_ref = http.read(); - let safe: std::collections::HashMap<&String, &String> = http_ref + let safe: std::collections::HashMap<&String, &String> = http .request_headers .iter() .filter(|(k, _)| { @@ -701,7 +700,8 @@ mod tests { #[test] fn test_view_with_extensions() { - use crate::extensions::{SecurityExtension, Guarded, HttpExtension}; + use std::sync::Arc; + use crate::extensions::{SecurityExtension, HttpExtension}; let mut security = SecurityExtension::default(); security.add_label("PII"); @@ -710,8 +710,8 @@ mod tests { http.set_header("Authorization", "Bearer tok"); let ext = Extensions { - security: Some(security), - http: Some(Guarded::new(http)), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), ..Default::default() }; @@ -768,7 +768,7 @@ mod tests { fn test_to_dict_with_extensions() { use std::sync::Arc; use crate::extensions::{ - SecurityExtension, Guarded, HttpExtension, RequestExtension, AgentExtension, + SecurityExtension, HttpExtension, RequestExtension, AgentExtension, }; let mut security = SecurityExtension::default(); @@ -785,8 +785,8 @@ mod tests { http.set_header("X-Request-ID", "req-123"); let ext = Extensions { - security: Some(security), - http: Some(Guarded::new(http)), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), request: Some(Arc::new(RequestExtension { environment: Some("production".into()), ..Default::default() diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index ee678016..20a80a86 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -427,9 +427,9 @@ impl Executor { if let Some(mp) = erased.modified_payload { *payload = mp; } - if let Some(me) = erased.modified_extensions { + if let Some(owned) = erased.modified_extensions { // Validate tier constraints before accepting - if !extensions.validate_immutable(&me) { + if !extensions.validate_immutable(&owned) { warn!( "{} plugin '{}' violated immutable tier — \ modified an immutable extension slot. \ @@ -437,7 +437,7 @@ impl Executor { phase_label, plugin_name ); } else if let Some(ref orig_sec) = extensions.security { - if let Some(ref new_sec) = me.security { + if let Some(ref new_sec) = owned.security { if !new_sec.labels.is_superset(&orig_sec.labels) { warn!( "{} plugin '{}' violated monotonic tier — \ @@ -446,13 +446,13 @@ impl Executor { phase_label, plugin_name ); } else { - *extensions = me; + extensions.merge_owned(owned); } } else { - *extensions = me; + extensions.merge_owned(owned); } } else { - *extensions = me; + extensions.merge_owned(owned); } } } @@ -809,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, } @@ -894,10 +894,11 @@ mod tests { let mut security = crate::extensions::SecurityExtension::default(); security.add_label("PII"); let ext = Extensions { - security: Some(security), + security: Some(Arc::new(security)), ..Default::default() }; - let result: PluginResult = PluginResult::modify_extensions(ext); + 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); 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/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 17b2d91e..18bca78b 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -11,8 +11,9 @@ // Mirrors cpex/framework/extensions/tiers.py::filter_extensions(). use std::collections::HashSet; +use std::sync::Arc; -use crate::hooks::payload::Extensions; +use super::container::Extensions; use super::security::{SecurityExtension, SubjectExtension}; use super::tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; @@ -279,7 +280,7 @@ pub fn filter_extensions( // Security — granular sub-field filtering if let Some(ref security) = extensions.security { - filtered.security = Some(build_filtered_security(security, capabilities)); + filtered.security = Some(Arc::new(build_filtered_security(security, capabilities))); } filtered @@ -362,7 +363,7 @@ fn build_filtered_subject( mod tests { use super::*; use crate::extensions::SecurityExtension; - use crate::hooks::payload::MetaExtension; + use crate::extensions::meta::MetaExtension; fn make_full_extensions() -> Extensions { let mut security = SecurityExtension::default(); @@ -385,22 +386,22 @@ mod tests { request_id: Some("req-001".into()), ..Default::default() })), - security: Some(security), - http: Some(crate::extensions::Guarded::new(http)), + 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(super::super::DelegationExtension { + 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([("key".to_string(), serde_json::json!("value"))].into()), + custom: Some(Arc::new([("key".to_string(), serde_json::json!("value"))].into())), ..Default::default() } } @@ -422,7 +423,7 @@ mod tests { assert!(filtered.delegation.is_none()); // Security: objects/data/classification visible, labels/subject hidden - let sec = filtered.security.unwrap(); + let sec = filtered.security.as_ref().unwrap(); assert!(sec.labels.is_empty()); assert!(sec.subject.is_none()); assert_eq!(sec.classification, Some("confidential".into())); @@ -436,7 +437,7 @@ mod tests { assert!(filtered.http.is_some()); assert_eq!( - filtered.http.unwrap().read().get_header("Authorization"), + filtered.http.unwrap().get_header("Authorization"), Some("Bearer token123") ); // Still no agent access @@ -463,7 +464,7 @@ mod tests { let caps: HashSet = ["read_labels".to_string()].into(); let filtered = filter_extensions(&ext, &caps); - let sec = filtered.security.unwrap(); + let sec = filtered.security.as_ref().unwrap(); assert!(sec.has_label("PII")); // No subject access — just label access assert!(sec.subject.is_none()); @@ -475,8 +476,8 @@ mod tests { let caps: HashSet = ["read_subject".to_string()].into(); let filtered = filter_extensions(&ext, &caps); - let sec = filtered.security.unwrap(); - let subject = sec.subject.unwrap(); + 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()); @@ -491,8 +492,8 @@ mod tests { let caps: HashSet = ["read_roles".to_string()].into(); let filtered = filter_extensions(&ext, &caps); - let sec = filtered.security.unwrap(); - let subject = sec.subject.unwrap(); + 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 @@ -527,9 +528,9 @@ mod tests { assert!(filtered.agent.is_some()); assert!(filtered.delegation.is_some()); - let sec = filtered.security.unwrap(); + let sec = filtered.security.as_ref().unwrap(); assert!(sec.has_label("PII")); - let subject = sec.subject.unwrap(); + let subject = sec.subject.as_ref().unwrap(); assert!(subject.roles.contains("admin")); assert!(subject.permissions.contains("read_all")); assert!(subject.teams.contains("engineering")); diff --git a/crates/cpex-core/src/extensions/meta.rs b/crates/cpex-core/src/extensions/meta.rs index 99f9a7a2..4ba55516 100644 --- a/crates/cpex-core/src/extensions/meta.rs +++ b/crates/cpex-core/src/extensions/meta.rs @@ -12,19 +12,31 @@ use serde::{Deserialize, Serialize}; /// Host-provided operational metadata. /// -/// Carries entity identification for route resolution, tags for -/// policy group inheritance, scope for host-defined grouping, -/// and arbitrary properties. +/// 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. +/// 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, skip_serializing_if = "Option::is_none")] + #[serde(default)] pub scope: Option, /// Arbitrary key-value metadata. diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs index 725cc17a..43235833 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -13,6 +13,7 @@ pub mod agent; pub mod completion; +pub mod container; pub mod delegation; pub mod filter; pub mod framework; @@ -27,15 +28,20 @@ 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::{ @@ -43,6 +49,4 @@ pub use security::{ SubjectExtension, SubjectType, }; pub use filter::{filter_extensions, SlotName}; -pub use guarded::{Guarded, WriteToken}; -pub use monotonic::{DeclassifierToken, MonotonicSet}; pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs index ebb6a330..2a9b2949 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -21,245 +21,16 @@ // modification without copying the payload. use std::any::Any; -use std::collections::HashMap; use std::fmt; -use std::sync::Arc; -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Extensions — full typed container -// --------------------------------------------------------------------------- - -// Re-export the MetaExtension with entity routing fields here -// since it has additional fields beyond the extensions::meta version -// (entity_type, entity_name for routing). +// 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::{ - AgentExtension, CompletionExtension, DelegationExtension, FrameworkExtension, Guarded, - HttpExtension, LLMExtension, MCPExtension, ProvenanceExtension, RequestExtension, - SecurityExtension, WriteToken, + Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken, }; -/// 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. -#[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, -} - -/// 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. -/// -/// Mirrors Python's `cpex.framework.extensions.Extensions`. -/// 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. -/// -/// **Tier enforcement:** -/// - **Immutable** (`Arc`) — shared by reference, zero-copy clone. -/// No `&mut` path exists. Plugins receive `&T` via auto-deref. -/// - **Monotonic** (`MonotonicSet`, append-only chain) — only `insert()` -/// / `append()` methods exposed. No `remove()` at compile time. -/// - **Guarded** (`Guarded`) — read via `.read()`, write via -/// `.write(token)` requiring a `WriteToken` from the framework. -/// - **Mutable** — standard types, freely modifiable. -/// -/// **Capability gating:** `filter_extensions()` builds a filtered -/// copy with `None` for slots the plugin can't see. Write tokens -/// are only set when the plugin declared the write capability. -/// -/// Mirrors Python's `cpex.framework.extensions.Extensions`. -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct Extensions { - /// Execution environment and request tracing (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request: Option>, - - /// Agent execution context — session, conversation, lineage (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option>, - - /// HTTP headers (guarded — requires WriteToken for mutation). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub http: Option>, - - /// Security — labels (monotonic add-only via MonotonicSet), - /// classification, subject, objects, data policies. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub security: Option, - - /// Delegation chain (monotonic — append-only). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option, - - /// MCP entity metadata — tool, resource, or prompt info (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp: Option>, - - /// LLM completion information (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub completion: Option>, - - /// Origin and message threading (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provenance: Option>, - - /// Model identity and capabilities (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option>, - - /// Agentic framework context (immutable, Arc). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub framework: Option>, - - /// Host-provided operational metadata — tags, scope, properties (immutable, Arc). - /// Also carries entity_type and entity_name for route resolution. - #[serde(default)] - pub meta: Option>, - - /// Custom extensions (mutable — no restrictions). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom: Option>, - - /// Write token for HTTP headers. Present only if the plugin - /// declared `write_headers` capability. Required for `http.write()`. - #[serde(skip)] - pub http_write_token: Option, - - /// Write token for label append. Present only if the plugin - /// declared `append_labels` capability. - #[serde(skip)] - pub labels_write_token: Option, - - /// Write token for delegation append. Present only if the plugin - /// declared `append_delegation` capability. - #[serde(skip)] - pub delegation_write_token: Option, -} - -impl Clone for Extensions { - /// Clone data fields. Immutable slots are Arc refcount bumps (~1ns). - /// Mutable/monotonic slots are deep cloned. Write tokens are NOT - /// cloned — they only exist on COW copies created by `cow_copy()`. - 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 clone for modification. - /// - /// Immutable slots share the same `Arc` (refcount bump, ~1ns). - /// Mutable/monotonic slots are deep cloned. - /// Write tokens are carried over from the original — only tokens - /// the executor set (based on trusted capabilities) are propagated. - /// No capability parameter needed — the plugin can't forge tokens - /// because it only has `&self` (immutable) access to the original. - /// - /// # Usage - /// - /// ```ignore - /// // In a plugin handler: - /// fn handle(&self, payload: &P, ext: &Extensions, ctx: &mut PluginContext) -> PluginResult

{ - /// let mut my_ext = ext.cow_copy(); - /// // Modify — only works if the executor gave us the write token - /// if let Some(ref token) = my_ext.http_write_token { - /// my_ext.http.as_mut().unwrap().write(token).set_header("X-Foo", "bar"); - /// } - /// PluginResult::modify_extensions(my_ext) - /// } - /// ``` - pub fn cow_copy(&self) -> Self { - let mut copy = self.clone(); // data cloned, tokens dropped - - // Carry over write tokens that the executor set on the original. - // Only tokens that already exist are propagated — can't escalate. - if self.http_write_token.is_some() { - copy.http_write_token = Some(WriteToken::new()); - } - if self.labels_write_token.is_some() { - copy.labels_write_token = Some(WriteToken::new()); - } - if self.delegation_write_token.is_some() { - copy.delegation_write_token = Some(WriteToken::new()); - } - - copy - } - - /// Validate that immutable slots were not tampered with. - /// - /// Uses `Arc::ptr_eq` to confirm immutable slots still point to - /// the same data. Called by the executor after a plugin returns - /// modified extensions. - pub fn validate_immutable(&self, modified: &Extensions) -> 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) - } -} - // --------------------------------------------------------------------------- // PluginPayload Trait // --------------------------------------------------------------------------- @@ -363,280 +134,3 @@ macro_rules! impl_plugin_payload { }; } -#[cfg(test)] -mod tests { - use super::*; - use crate::extensions::{ - DelegationExtension, Guarded, 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(security), - http: Some(Guarded::new(http)), - delegation: Some(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().read().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(security), - http: Some(Guarded::new(http)), - delegation: Some(DelegationExtension::default()), - custom: Some([("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().read().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.read().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/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index be5f8a45..e07c7ab0 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -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/manager.rs b/crates/cpex-core/src/manager.rs index f16b0a81..e72d17c7 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -2537,7 +2537,7 @@ routes: security.add_label("ORIGINAL"); let ext = Extensions { - security: Some(security), + security: Some(Arc::new(security)), ..Default::default() }; @@ -2632,7 +2632,7 @@ routes: }); let ext = Extensions { - security: Some(security), + security: Some(Arc::new(security)), ..Default::default() };