diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..856921c2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,984 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +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 = [ + "arc-swap", + "async-trait", + "futures", + "hashbrown 0.15.5", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", +] + +[[package]] +name = "cpex-demo-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-ffi", + "serde_json", + "tracing", +] + +[[package]] +name = "cpex-dynamic-plugin" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin-example", + "cpex-dynamic-plugin-multi-handler-example", + "cpex-dynamic-plugin-multi-plugin-example", + "libloading", + "paste", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-dynamic-plugin-example" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin", +] + +[[package]] +name = "cpex-dynamic-plugin-multi-handler-example" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin", +] + +[[package]] +name = "cpex-dynamic-plugin-multi-plugin-example" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin", +] + +[[package]] +name = "cpex-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "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 = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +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 = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +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_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +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", + "serde_core", + "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 = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + +[[package]] +name = "windows-link" +version = "0.2.1" +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..cba63e20 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +# 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", + "crates/cpex-ffi", + "crates/cpex-dynamic-plugin", + "crates/cpex-dynamic-plugin/examples/single-plugin", + "crates/cpex-dynamic-plugin/examples/multi-handler", + "crates/cpex-dynamic-plugin/examples/multi-plugin", + "examples/go-demo/ffi", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["Teryl Taylor"] + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } +serde = { version = "1", features = ["derive", "rc"] } +serde_yaml = "0.9" +serde_json = "1" +async-trait = "0.1" +thiserror = "2" +tracing = "0.1" +uuid = { version = "1", features = ["v4", "serde"] } +paste = "1" +futures = "0.3" +hashbrown = "0.15" +arc-swap = "1.7" +wildmatch = "2" +rmp-serde = "1" +serde_bytes = "0.11" diff --git a/Makefile b/Makefile index b806c970..ab47d986 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,36 @@ help: @echo " sdist Build source distribution only" @echo " verify Build and verify package with twine" @echo "" + @echo "Rust (cpex-core / cpex-ffi / cpex-sdk):" + @echo " rust-build Build the Rust workspace (debug)" + @echo " rust-build-release Build the Rust workspace (release)" + @echo " rust-test Run all Rust workspace tests" + @echo " rust-test-ffi Run only the cpex-ffi crate tests" + @echo " rust-fmt Format Rust code with rustfmt" + @echo " rust-clippy Run clippy on the Rust workspace" + @echo " rust-lint Auto-fix style + clippy issues (alias for rust-lint-fix)" + @echo " rust-lint-fix Same as rust-lint โ€” mutating fmt + clippy --fix" + @echo " rust-lint-check Read-only fmt --check + clippy (CI-safe)" + @echo " rust-clean Remove the Rust target/ directory" + @echo "" + @echo "Go (go/cpex):" + @echo " go-build Build the Go cpex package (requires libcpex_ffi)" + @echo " go-test Run Go tests" + @echo " go-test-race Run Go tests with the race detector" + @echo " go-fmt Format Go code with gofmt" + @echo " go-vet Run go vet" + @echo " go-lint Auto-fix style + lint issues (alias for go-lint-fix)" + @echo " go-lint-fix Same as go-lint โ€” gofmt -w + vet + golangci-lint --fix" + @echo " go-lint-check Read-only gofmt -l + vet + golangci-lint (CI-safe)" + @echo "" + @echo "Examples:" + @echo " examples-build Build all Rust + Go examples (catches stale APIs)" + @echo " examples-run Run all examples end-to-end" + @echo "" + @echo "End-to-end:" + @echo " test-all Run Rust workspace tests + Go tests w/ -race" + @echo " ci Lint-check + tests + examples-build (CI gate)" + @echo "" @echo "Utilities:" @echo " clean Remove all artifacts and builds" @echo " clean-all Remove artifacts, builds, and venv" @@ -402,6 +432,225 @@ env-example: @pip install settings-doc @settings-doc generate --class cpex.framework.settings.PluginsSettings --output-format dotenv > .env.template +# ============================================================================= +# Rust workspace (cpex-core, cpex-ffi, cpex-sdk) +# ============================================================================= + +CARGO ?= cargo +GO ?= go +GO_DIR = go/cpex + +.PHONY: rust-build +rust-build: + @echo "๐Ÿฆ€ Building Rust workspace (debug)..." + @$(CARGO) build --workspace + @echo "โœ… Rust workspace built" + +.PHONY: rust-build-release +rust-build-release: + @echo "๐Ÿฆ€ Building Rust workspace (release)..." + @$(CARGO) build --release --workspace + @echo "โœ… Rust workspace built (release)" + +.PHONY: rust-test +rust-test: + @echo "๐Ÿงช Running Rust workspace tests..." + @$(CARGO) test --workspace + @echo "โœ… Rust tests passed" + +.PHONY: rust-test-ffi +rust-test-ffi: + @echo "๐Ÿงช Running cpex-ffi tests..." + @$(CARGO) test -p cpex-ffi --lib + @echo "โœ… cpex-ffi tests passed" + +.PHONY: rust-fmt +rust-fmt: + @echo "๐Ÿฆ€ Formatting Rust code..." + @$(CARGO) fmt --all + @echo "โœ… Rust code formatted" + +.PHONY: rust-clippy +rust-clippy: + @echo "๐Ÿฆ€ Running clippy..." + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "โœ… Clippy clean" + +# rust-lint is a developer convenience: format the code, then apply +# clippy's auto-fixes. --allow-dirty/--allow-staged let clippy run on +# in-progress edits rather than refusing on a non-clean tree. +.PHONY: rust-lint +rust-lint: rust-lint-fix + +.PHONY: rust-lint-fix +rust-lint-fix: + @echo "๐Ÿฆ€ Formatting + auto-fixing Rust..." + @$(CARGO) fmt --all + @$(CARGO) clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings + @echo "โœ… Rust lint-fix complete" + +# rust-lint-check is the CI-safe variant: no writes. Fails if formatting +# drifted (fmt --check) or clippy has any warning. +.PHONY: rust-lint-check +rust-lint-check: + @echo "๐Ÿฆ€ Checking Rust formatting + clippy (read-only)..." + @$(CARGO) fmt --all -- --check + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "โœ… Rust lint-check passed" + +.PHONY: rust-clean +rust-clean: + @echo "๐Ÿงน Removing Rust target directory..." + @$(CARGO) clean + @echo "โœ… target/ removed" + +# ============================================================================= +# Go bindings (go/cpex) +# ============================================================================= +# +# go/cpex links against the cpex-ffi cdylib at target/release. Targets +# below that touch Go ensure the release build is current first โ€” Go's +# linker errors on missing libcpex_ffi.dylib are easy to misread. + +.PHONY: go-build +go-build: rust-build-release + @echo "๐Ÿน Building Go cpex package..." + @cd $(GO_DIR) && $(GO) build ./... + @echo "โœ… Go package built" + +.PHONY: go-test +go-test: rust-build-release + @echo "๐Ÿงช Running Go tests..." + @cd $(GO_DIR) && $(GO) test -count=1 ./... + @echo "โœ… Go tests passed" + +.PHONY: go-test-race +go-test-race: rust-build-release + @echo "๐Ÿงช Running Go tests with race detector..." + @cd $(GO_DIR) && $(GO) test -count=1 -race ./... + @echo "โœ… Go tests passed (with -race)" + +.PHONY: go-vet +go-vet: rust-build-release + @echo "๐Ÿน Running go vet..." + @cd $(GO_DIR) && $(GO) vet ./... + @echo "โœ… go vet clean" + +# go-fmt rewrites .go files in place via gofmt. Read-only counterpart +# is `gofmt -l`, used inside go-lint-check. +.PHONY: go-fmt +go-fmt: + @echo "๐Ÿน Formatting Go code..." + @cd $(GO_DIR) && $(GO) fmt ./... + @echo "โœ… Go code formatted" + +# go-lint is a developer convenience: format, vet, then run +# golangci-lint with --fix. We require golangci-lint to be installed โ€” +# print an install hint rather than silently skipping it (skipping +# would let style drift land unnoticed). +GOLANGCI_LINT ?= golangci-lint + +.PHONY: go-lint +go-lint: go-lint-fix + +.PHONY: go-lint-fix +go-lint-fix: rust-build-release + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + echo "โŒ golangci-lint not found. Install:"; \ + echo " brew install golangci-lint"; \ + echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + } + @echo "๐Ÿน Formatting + auto-fixing Go..." + @cd $(GO_DIR) && $(GO) fmt ./... + @cd $(GO_DIR) && $(GO) vet ./... + @cd $(GO_DIR) && $(GOLANGCI_LINT) run --fix ./... + @echo "โœ… Go lint-fix complete" + +# go-lint-check is the CI-safe variant: read-only. `gofmt -l` lists +# files that would be reformatted and we fail if that list is non-empty. +.PHONY: go-lint-check +go-lint-check: rust-build-release + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + echo "โŒ golangci-lint not found. Install:"; \ + echo " brew install golangci-lint"; \ + echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + } + @echo "๐Ÿน Checking Go formatting + vet + golangci-lint (read-only)..." + @cd $(GO_DIR) && unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "โŒ Files need formatting:"; echo "$$unformatted"; \ + exit 1; \ + fi + @cd $(GO_DIR) && $(GO) vet ./... + @cd $(GO_DIR) && $(GOLANGCI_LINT) run ./... + @echo "โœ… Go lint-check passed" + +# ============================================================================= +# Examples +# ============================================================================= +# +# Building examples is the cheapest way to catch stale public-API usage: +# cargo test / go test only build code reachable from tests, so an +# example file using a renamed function compiles fine in isolation but +# breaks at example-build time. Wire this into CI. + +GO_EXAMPLES_DIR = examples/go-demo + +.PHONY: rust-examples-build +rust-examples-build: + @echo "๐Ÿฆ€ Building Rust examples..." + @$(CARGO) build --examples --workspace + @echo "โœ… Rust examples built" + +.PHONY: go-examples-build +go-examples-build: rust-build-release + @echo "๐Ÿน Building Go examples..." + @cd $(GO_EXAMPLES_DIR) && $(GO) build ./... + @echo "โœ… Go examples built" + +.PHONY: examples-build +examples-build: rust-examples-build go-examples-build + @echo "โœ… All examples built" + +# Running examples โ€” useful for manual smoke-testing. Output goes to +# stdout and may be noisy. Each example is self-contained: prints +# scenario output and exits 0 on success. +.PHONY: examples-run +examples-run: examples-build + @echo "๐Ÿƒ Running cpex-core plugin_demo..." + @$(CARGO) run --example plugin_demo -p cpex-core --quiet >/dev/null + @echo "โœ… plugin_demo OK" + @echo "๐Ÿƒ Running cpex-core cmf_capabilities_demo..." + @$(CARGO) run --example cmf_capabilities_demo -p cpex-core --quiet >/dev/null + @echo "โœ… cmf_capabilities_demo OK" + @echo "๐Ÿƒ Running go-demo (generic payload)..." + @cd $(GO_EXAMPLES_DIR) && $(GO) run . >/dev/null + @echo "โœ… go-demo OK" + @echo "๐Ÿƒ Running go-demo cmf-demo..." + @cd $(GO_EXAMPLES_DIR) && $(GO) run ./cmd/cmf-demo >/dev/null + @echo "โœ… cmf-demo OK" + @echo "โœ… All examples ran successfully" + +# ============================================================================= +# End-to-end +# ============================================================================= + +# test-all bundles the Rust workspace tests and the Go tests under +# the race detector. Skips the Python pytest suite โ€” use +# `make test rust-test go-test-race` if you want all three. +.PHONY: test-all +test-all: rust-test go-test-race + @echo "โœ… Rust + Go test suites passed" + +# ci is the canonical CI gate: read-only lint checks, full test +# suites, and example builds. If this passes locally, the same checks +# will pass in CI. +.PHONY: ci +ci: rust-lint-check test-all examples-build + @echo "โœ… CI gate passed (lint + tests + examples)" + # ============================================================================= # Development shortcuts # ============================================================================= diff --git a/crates/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..2885700f --- /dev/null +++ b/crates/cpex-core/Cargo.toml @@ -0,0 +1,31 @@ +# 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 } +tokio-util = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +futures = { workspace = true } +hashbrown = { workspace = true } +arc-swap = { workspace = true } +wildmatch = { workspace = true } diff --git a/crates/cpex-core/examples/README.md b/crates/cpex-core/examples/README.md new file mode 100644 index 00000000..c3962e31 --- /dev/null +++ b/crates/cpex-core/examples/README.md @@ -0,0 +1,80 @@ +# CPEX Core Examples + +## plugin_demo + +A complete end-to-end example showing how to build plugins, load config, and invoke hooks with the CPEX runtime. + +### What it demonstrates + +- **Defining hook types and payloads** โ€” `ToolPreInvoke` and `ToolPostInvoke` hooks with a shared `ToolInvokePayload` +- **Building plugins** โ€” three plugins (`IdentityResolver`, `PiiGuard`, `AuditLogger`) implementing `Plugin` + `HookHandler` for different hook types +- **Multi-hook registration** โ€” a single plugin instance (e.g., `IdentityResolver`) registered for multiple hooks (`tool_pre_invoke` and `tool_post_invoke`) via the factory pattern +- **Plugin factories** โ€” `PluginFactory` implementations that create plugin instances and wire up typed handler adapters +- **YAML config loading** โ€” `plugin_demo.yaml` declares plugins, policy groups, and routing rules +- **Policy groups and tag-based routing** โ€” the `pii` policy group activates `PiiGuard` only for tools tagged with `pii` +- **Route resolution** โ€” exact tool matches, wildcard catch-all, tag-driven plugin selection +- **PluginContext** โ€” `global_state` used to pass PII clearance between hooks, `local_state` for per-plugin scratch data +- **BackgroundTasks** โ€” fire-and-forget plugins (`AuditLogger`) spawn background tasks; `wait_for_background_tasks()` awaits them +- **PluginContextTable** โ€” context table threaded from pre-invoke to post-invoke to preserve plugin state + +### Running + +From the workspace root: + +``` +cargo run --example plugin_demo +``` + +### Scenarios + +The demo runs five scenarios against three registered plugins: + +| Scenario | Tool | User | Outcome | +|----------|------|------|---------| +| 1 | get_compensation | alice (no clearance) | DENIED by pii-guard | +| 2 | get_compensation | alice (with clearance) | ALLOWED, then post-invoke fires | +| 3 | list_departments | bob | ALLOWED (no PII tag, pii-guard skipped) | +| 4 | some_other_tool | charlie | ALLOWED (wildcard route) | +| 5 | list_departments | (empty) | DENIED by identity-resolver | + +### Files + +- `plugin_demo.rs` โ€” Rust source with plugins, factories, and main +- `plugin_demo.yaml` โ€” YAML config with plugins, policy groups, and routes + +--- + +## cmf_capabilities_demo + +Demonstrates CMF messages with capability-gated extension access. Shows how different plugins see different views of the same extensions based on their declared capabilities. + +### What it demonstrates + +- **CMF Message** โ€” typed content parts (`Text`, `ToolCall`) with the standard CMF format +- **Capability gating** โ€” plugins declare capabilities in YAML config; the executor filters extensions per plugin +- **Security labels** โ€” `MonotonicSet` (add-only, no remove at compile time) +- **Guarded HTTP headers** โ€” `.read()` is free, `.write(token)` requires a `WriteToken` +- **COW copy** โ€” `extensions.cow_copy()` for plugins that need to modify; zero-cost for read-only plugins +- **Write tokens** โ€” executor sets tokens based on capabilities; propagated through `cow_copy()` +- **Three capability levels** โ€” identity-checker (security), header-injector (http + labels), audit-logger (http + labels read-only) + +### Running + +From the workspace root: + +``` +cargo run --example cmf_capabilities_demo +``` + +### What each plugin sees + +| Plugin | Capabilities | Security Labels | Subject | HTTP Headers | Can Write | +|--------|-------------|-----------------|---------|--------------|-----------| +| identity-checker | read_labels, read_subject, read_roles | visible | visible (id + roles) | hidden | no | +| header-injector | read_headers, write_headers, append_labels | visible | hidden | visible | yes (headers + labels) | +| audit-logger | read_headers, read_labels | visible | hidden | visible | no (audit mode) | + +### Files + +- `cmf_capabilities_demo.rs` โ€” Rust source with CMF plugins and capability-gated access +- `cmf_capabilities_demo.yaml` โ€” YAML config with per-plugin capabilities diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs new file mode 100644 index 00000000..8843a30e --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -0,0 +1,498 @@ +// CMF Capabilities Demo +// +// Demonstrates: +// 1. CMF Message with typed content parts (tool call) +// 2. Extensions with security, HTTP, and meta populated +// 3. Config-driven capability gating โ€” plugins only see what they declare +// 4. COW copy for extension modification with write tokens +// 5. MonotonicSet labels (add-only, no remove) +// 6. Guarded HTTP headers (read free, write needs token) +// +// Run with: cargo run --example cmf_capabilities_demo + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::{HttpExtension, RequestExtension, SecurityExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::{Extensions, MetaExtension}; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// --------------------------------------------------------------------------- +// Plugin: IdentityChecker +// Has read_security, read_labels, read_subject, read_roles capabilities. +// Checks if the caller has the required role. +// --------------------------------------------------------------------------- + +struct IdentityChecker { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityChecker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for IdentityChecker { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Determine if this is pre or post invoke based on message content + let is_result = payload.message.is_tool_result(); + + if is_result { + // POST-INVOKE: verify the tool result came from an authorized call + let tool_name = payload + .message + .get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown"); + println!( + " [identity-checker] POST-INVOKE: verifying result from '{}'", + tool_name + ); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + println!( + " [identity-checker] Result authorized for subject: {:?}", + subject.id + ); + } + } + println!(" [identity-checker] POST-INVOKE ALLOWED"); + } else { + // PRE-INVOKE: check caller identity and roles + let tool_name = payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + println!( + " [identity-checker] PRE-INVOKE: checking identity for '{}'", + tool_name + ); + + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + println!(" [identity-checker] Security labels: {:?}", labels); + + if let Some(ref subject) = security.subject { + println!( + " [identity-checker] Subject: {:?}, Roles: {:?}", + subject.id, + subject.roles.iter().collect::>() + ); + + if security.has_label("PII") && !subject.roles.contains("hr_admin") { + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + format!("Tool '{}' requires 'hr_admin' role for PII data", tool_name), + )); + } + } + } + + if extensions.http.is_some() { + println!(" [identity-checker] WARNING: HTTP visible (unexpected!)"); + } else { + println!(" [identity-checker] HTTP: not visible (correct โ€” no read_headers)"); + } + println!(" [identity-checker] PRE-INVOKE ALLOWED"); + } + + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Plugin: HeaderInjector +// Has read_headers, write_headers, append_labels capabilities. +// Uses COW to add a security label and inject a header. +// --------------------------------------------------------------------------- + +struct HeaderInjector { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for HeaderInjector { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for HeaderInjector { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Can see HTTP (has read_headers) + if let Some(ref http) = extensions.http { + println!( + " [header-injector] HTTP headers visible: {:?}", + http.request_headers + ); + } + + // Can NOT see security subject (no read_subject) + if let Some(ref security) = extensions.security { + if security.subject.is_some() { + println!(" [header-injector] WARNING: Subject visible (unexpected!)"); + } else { + println!(" [header-injector] Security subject: not visible (no read_subject)"); + } + } + + // COW copy to modify โ€” tokens propagate from the executor + let mut modified = extensions.cow_copy(); + + // Add a label via MonotonicSet (has append_labels) + if modified.labels_write_token.is_some() { + modified.security.as_mut().unwrap().add_label("PROCESSED"); + println!(" [header-injector] Added label 'PROCESSED'"); + } + + // Inject a header via Guarded (has write_headers) + if let Some(ref token) = modified.http_write_token { + modified + .http + .as_mut() + .unwrap() + .write(token) + .set_header("X-Processed-By", "header-injector"); + println!(" [header-injector] Injected header 'X-Processed-By'"); + } + + PluginResult::modify_extensions(modified) + } +} + +// --------------------------------------------------------------------------- +// Plugin: AuditLogger +// Has read_headers, read_security, read_labels capabilities. +// Read-only โ€” just logs what it can see. +// --------------------------------------------------------------------------- + +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLogger { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let is_result = payload.message.is_tool_result(); + let phase = if is_result { "POST" } else { "PRE" }; + + let tool_name = if is_result { + payload + .message + .get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown") + } else { + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown") + }; + + print!(" [audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name); + + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + print!("labels={:?} ", labels); + } + + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + print!("request_id='{}' ", req_id); + } + } + + if is_result { + let is_error = payload + .message + .get_tool_results() + .first() + .map(|tr| tr.is_error) + .unwrap_or(false); + print!("error={} ", is_error); + } + + println!(); + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +struct IdentityCheckerFactory; +impl PluginFactory for IdentityCheckerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityChecker { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +struct HeaderInjectorFactory; +impl PluginFactory for HeaderInjectorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(HeaderInjector { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +struct AuditLoggerFactory; +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLogger { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + println!("=== CMF Capabilities Demo ===\n"); + + // Load config from YAML file โ€” capabilities declared per plugin + let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml"; + println!("--- Loading config from {} ---\n", config_path); + let yaml = std::fs::read_to_string(config_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); + let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory)); + mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory)); + mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // --- Build CMF Message --- + let payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Looking up compensation.".into(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: [("employee_id".to_string(), serde_json::json!(42))].into(), + namespace: None, + }, + }, + ], + channel: None, + }, + }; + + // --- Build Extensions with security, HTTP, meta --- + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + security.classification = Some("confidential".into()); + security.subject = Some(cpex_core::extensions::security::SubjectExtension { + id: Some("alice".into()), + subject_type: Some(cpex_core::extensions::security::SubjectType::User), + roles: ["hr_admin".to_string()].into(), + permissions: ["read_compensation".to_string()].into(), + ..Default::default() + }); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer eyJ..."); + http.set_header("X-Request-ID", "req-abc-123"); + + let ext = Extensions { + request: Some(Arc::new(RequestExtension { + environment: Some("production".into()), + request_id: Some("req-abc-123".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: ["pii".to_string(), "hr".to_string()].into(), + ..Default::default() + })), + ..Default::default() + }; + + // --- Pre-invoke: type-safe dispatch via invoke_named --- + println!("=== Phase 1: cmf.tool_pre_invoke ===\n"); + + // invoke_named gives compile-time payload type checking + // while routing to the specific "cmf.tool_pre_invoke" hook name + let (pre_result, bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + ext, + None, // first hook โ€” no context table + ) + .await; + + println!(); + if pre_result.continue_processing { + println!("Pre-invoke result: ALLOWED"); + if let Some(ref modified_ext) = pre_result.modified_extensions { + if let Some(ref sec) = modified_ext.security { + let labels: Vec<&String> = sec.labels.iter().collect(); + println!(" Labels after pre-invoke: {:?}", labels); + } + if let Some(ref http) = modified_ext.http { + println!(" Headers after pre-invoke: {:?}", http.request_headers); + } + } + } else { + println!( + "Pre-invoke result: DENIED โ€” {}", + pre_result.violation.as_ref().unwrap().reason + ); + bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); + return; + } + bg.wait_for_background_tasks().await; + + // --- Simulate tool execution --- + println!("\n--- Tool 'get_compensation' executes... ---"); + println!(" Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n"); + + // --- Post-invoke: different CMF message with tool result --- + println!("=== Phase 2: cmf.tool_post_invoke ===\n"); + + let post_payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Tool, + content: vec![ContentPart::ToolResult { + content: cpex_core::cmf::ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_compensation".into(), + content: serde_json::json!({"salary": 150000, "currency": "USD"}), + is_error: false, + }, + }], + channel: None, + }, + }; + + // Build post-invoke extensions โ€” carry forward any modifications + // from pre-invoke via the context table + let post_ext = pre_result.modified_extensions.unwrap_or_else(|| { + // Rebuild if no modifications + let mut security = SecurityExtension::default(); + security.add_label("PII"); + Extensions { + security: Some(Arc::new(security)), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + } + }); + + // Thread the context table from pre-invoke to preserve plugin state + let (post_result, post_bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + post_ext, + Some(pre_result.context_table), + ) + .await; + + println!(); + if post_result.continue_processing { + println!("Post-invoke result: ALLOWED"); + } else { + println!( + "Post-invoke result: DENIED โ€” {}", + post_result.violation.as_ref().unwrap().reason + ); + } + + post_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); +} diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.yaml b/crates/cpex-core/examples/cmf_capabilities_demo.yaml new file mode 100644 index 00000000..ace0f5cb --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.yaml @@ -0,0 +1,50 @@ +# CMF Capabilities Demo Configuration +# +# Three plugins with different capabilities see different views +# of the same extensions. Demonstrates capability-gated access +# across pre-invoke and post-invoke hooks. + +plugin_settings: + routing_enabled: true + +global: + policies: + all: + plugins: [identity-checker, header-injector, audit-logger] + +plugins: + - name: identity-checker + kind: builtin/identity-checker + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + - read_roles + + - name: header-injector + kind: builtin/header-injector + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: + - read_headers + - write_headers + - append_labels + + - name: audit-logger + kind: builtin/audit-logger + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: audit + priority: 100 + on_error: ignore + capabilities: + - read_headers + - read_labels + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs new file mode 100644 index 00000000..12cfd3f5 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -0,0 +1,556 @@ +// CPEX Plugin Demo +// +// Demonstrates how to: +// 1. Define hook types and payloads +// 2. Build plugins that implement HookHandler +// 3. Create plugin factories for config-driven loading +// 4. Load a YAML config with routing rules +// 5. Invoke hooks with MetaExtension for route resolution +// +// Run with: cargo run --example plugin_demo + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::executor::PipelineResult; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::{Extensions, MetaExtension}; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// --------------------------------------------------------------------------- +// Step 1: Define a payload and hook type +// --------------------------------------------------------------------------- + +/// The payload carried through the tool_pre_invoke hook. +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} +cpex_core::impl_plugin_payload!(ToolInvokePayload); + +/// Hook type for tool_pre_invoke โ€” runs before a tool executes. +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +/// Hook type for tool_post_invoke โ€” runs after a tool executes. +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Step 2: Build plugins +// --------------------------------------------------------------------------- + +/// Identity resolver โ€” checks that a user is present. +struct IdentityResolver { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + println!(" [identity-resolver] initialized"); + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + println!(" [identity-resolver] shutdown"); + Ok(()) + } +} + +impl HookHandler for IdentityResolver { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + println!(" [identity-resolver] DENIED: no user identity"); + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + println!( + " [identity-resolver] OK: user '{}' identified", + payload.user + ); + PluginResult::allow() + } +} + +impl HookHandler for IdentityResolver { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!( + " [identity-resolver] post-invoke: user '{}' completed '{}'", + payload.user, payload.tool_name + ); + PluginResult::allow() + } +} + +/// PII guard โ€” blocks access to sensitive tools without clearance. +struct PiiGuard { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for PiiGuard { + fn config(&self) -> &PluginConfig { + &self.cfg + } + // initialize() and shutdown() use defaults โ€” no setup needed +} + +impl HookHandler for PiiGuard { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Check if the user has PII clearance (simulated via context) + let has_clearance = ctx + .get_global("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_clearance { + println!( + " [pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", + payload.user, payload.tool_name + ); + return PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required", + )); + } + + println!( + " [pii-guard] OK: user '{}' has PII clearance", + payload.user + ); + PluginResult::allow() + } +} + +/// Audit logger โ€” logs all tool invocations (fire-and-forget). +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } + // initialize() and shutdown() use defaults โ€” no setup needed +} + +impl HookHandler for AuditLogger { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!( + " [audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, payload.tool_name, payload.arguments + ); + PluginResult::allow() + } +} + +impl HookHandler for AuditLogger { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!( + " [audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, payload.tool_name + ); + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Awaiting plugin example โ€” RemoteAuthz +// --------------------------------------------------------------------------- +// +// `HookHandler` is async by design โ€” `handle` is `async fn`. +// Plugins that don't need to `.await` anything still write +// `async fn handle` and return synchronously; this plugin shows the +// other direction, where the body genuinely awaits per-invocation +// work. The realistic version would call a remote authz service +// (gRPC, HTTP, OPA, Cedarling, etc.); here we simulate the network +// round-trip with a small `tokio::time::sleep` so the demo runs +// offline. +// +// Key things this shows: +// 1. Per-request latency state is *cached at init* โ€” the handler +// consults the in-memory ACL and only "calls out" on a miss. +// Hot-path I/O is the most common source of latency regressions +// in plugins, so prefer initialize-time loading wherever you can. +// 2. Registration uses the exact same factory pattern as any other +// plugin โ€” `TypedHandlerAdapter::` and the same +// `register_factory` call. There is no separate async path. +struct RemoteAuthz { + cfg: PluginConfig, + /// ACL "fetched" at init. Populated in Plugin::initialize. + allowed_users: tokio::sync::RwLock>, +} + +#[async_trait] +impl Plugin for RemoteAuthz { + fn config(&self) -> &PluginConfig { + &self.cfg + } + /// Pretend we're loading the ACL from a remote service. In a real + /// plugin this would be `client.fetch_acl().await`; we simulate + /// the round-trip with a small sleep so the demo runs offline. + async fn initialize(&self) -> Result<(), Box> { + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + let mut acl = self.allowed_users.write().await; + acl.extend(["alice", "bob"].iter().map(|s| s.to_string())); + println!( + " [remote-authz] initialized โ€” ACL cached ({} users)", + acl.len() + ); + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + println!(" [remote-authz] shutdown"); + Ok(()) + } +} + +impl HookHandler for RemoteAuthz { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Cache hit path โ€” fast. + let acl = self.allowed_users.read().await; + if acl.contains(&payload.user) { + println!( + " [remote-authz] OK (cache hit): user '{}' allowed", + payload.user + ); + return PluginResult::allow(); + } + drop(acl); // release read lock before the fake remote call + // Cache miss path โ€” simulate a remote authz check. In a real + // plugin this is where you'd `.await` a gRPC or HTTP call. + // The latency cost is real and shows up on the request path. + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + println!( + " [remote-authz] DENIED (cache miss + remote check): user '{}'", + payload.user + ); + PluginResult::deny(PluginViolation::new( + "remote_authz_denied", + format!("User '{}' not in remote ACL", payload.user), + )) + } +} + +// --------------------------------------------------------------------------- +// Step 3: Create plugin factories +// --------------------------------------------------------------------------- + +struct IdentityFactory; +impl PluginFactory for IdentityFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityResolver { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +struct PiiGuardFactory; +impl PluginFactory for PiiGuardFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(PiiGuard { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +struct AuditLoggerFactory; +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLogger { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +/// Factory for the async plugin. Note the factory body is identical +/// in shape to the sync factories above โ€” `TypedHandlerAdapter` and +/// the `register_factory` path don't care that the underlying handler +/// is async. The framework hides the choice. +struct RemoteAuthzFactory; +impl PluginFactory for RemoteAuthzFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(RemoteAuthz { + cfg: config.clone(), + allowed_users: tokio::sync::RwLock::new(std::collections::HashSet::new()), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Step 4: Build extensions with MetaExtension for routing +// --------------------------------------------------------------------------- + +fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + tags: tags.iter().map(|s| s.to_string()).collect(), + ..Default::default() + })), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Helper to print results +// --------------------------------------------------------------------------- + +fn print_result(_label: &str, result: &PipelineResult) { + if result.continue_processing { + println!(" Result: ALLOWED"); + } else { + let violation = result.violation.as_ref().unwrap(); + println!( + " Result: DENIED by '{}' โ€” {} [{}]", + violation.plugin_name.as_deref().unwrap_or("unknown"), + violation.reason, + violation.code, + ); + } + println!(); +} + +// --------------------------------------------------------------------------- +// Step 5: Main โ€” load config, invoke hooks, see results +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + println!("=== CPEX Plugin Demo ===\n"); + + // --- Load config from YAML file --- + let config_path = "crates/cpex-core/examples/plugin_demo.yaml"; + println!("--- Loading config from {} ---\n", config_path); + let yaml = std::fs::read_to_string(config_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); + let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("builtin/identity", Box::new(IdentityFactory)); + mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory)); + mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); + mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory)); + mgr.load_config(cpex_config).unwrap(); + + println!("\n--- Initializing plugins ---\n"); + mgr.initialize().await.unwrap(); + + println!("\nPlugins loaded: {}", mgr.plugin_count()); + println!( + "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", + mgr.has_hooks_for("tool_pre_invoke"), + mgr.has_hooks_for("tool_post_invoke"), + ); + + // --- Scenario 1: PII tool without clearance --- + println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("get_compensation (no clearance)", &result); + // Wait for any fire-and-forget tasks + bg.wait_for_background_tasks().await; + + // --- Scenario 2: PII tool with clearance --- + println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + // Simulate clearance by pre-populating global_state + // (In production, an earlier hook would set this from a token claim) + let mut ctx_table = cpex_core::context::PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + let (result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + print_result("get_compensation (with clearance)", &result); + bg.wait_for_background_tasks().await; + + // Now call post-invoke โ€” threads the context table from pre-invoke + println!(" --- post-invoke for get_compensation ---\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let (post_result, bg) = mgr + .invoke::(payload, ext, Some(result.context_table)) + .await; + print_result("get_compensation post-invoke", &post_result); + bg.wait_for_background_tasks().await; + + // --- Scenario 3: Non-PII tool --- + println!("=== Scenario 3: list_departments (non-PII tool) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "bob".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("list_departments", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 4: Unknown tool (wildcard route) --- + println!("=== Scenario 4: some_other_tool (wildcard route) ===\n"); + let payload = ToolInvokePayload { + tool_name: "some_other_tool".into(), + user: "charlie".into(), + arguments: "foo=bar".into(), + }; + let ext = make_tool_extensions("some_other_tool", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("some_other_tool (wildcard)", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 5: Awaiting plugin โ€” cache hit --- + // RemoteAuthz's `handle` is `async fn` and reads from a tokio + // RwLock. Its initialize() pre-loaded an ACL containing "alice" + // and "bob"; this call exercises the cache-hit fast path. + println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n"); + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_tool_extensions("query_external_data", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("query_external_data (alice โ€” in ACL)", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 6: Awaiting plugin โ€” cache miss path with .await --- + // "charlie" is not in the cached ACL, so RemoteAuthz takes the + // cache-miss branch and `.await`s a simulated remote call before + // denying. + println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n"); + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_tool_extensions("query_external_data", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("query_external_data (charlie โ€” not in ACL)", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 7: No user identity --- + println!("=== Scenario 7: list_departments (no user identity) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("list_departments (no user)", &result); + bg.wait_for_background_tasks().await; + + // --- Shutdown --- + println!("--- Shutting down ---\n"); + mgr.shutdown().await; + + println!("=== Demo complete ==="); +} diff --git a/crates/cpex-core/examples/plugin_demo.yaml b/crates/cpex-core/examples/plugin_demo.yaml new file mode 100644 index 00000000..07051e95 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.yaml @@ -0,0 +1,84 @@ +# CPEX Plugin Demo Configuration +# +# Three plugins, policy groups with tag-based activation, +# and routes that map tools to different plugin combinations. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + # "all" is reserved โ€” these plugins fire on every invocation + all: + plugins: [identity-resolver] + # "pii" group โ€” activated when a route has the "pii" tag + pii: + plugins: [pii-guard] + # "external_authz" group โ€” activated when a route has the + # "needs_remote_authz" tag. Fires the async RemoteAuthz plugin. + external_authz: + plugins: [remote-authz] + +plugins: + - name: identity-resolver + kind: builtin/identity + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + config: + clearance_level: confidential + + # Awaiting plugin โ€” its `handle` body uses `.await` for a + # (simulated) remote authz call on cache miss. Wired in exactly + # the same way as plugins whose `handle` body has no `.await`; + # registration is identical either way. + - name: remote-authz + kind: builtin/remote_authz + hooks: [tool_pre_invoke] + mode: sequential + priority: 30 + on_error: fail + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + # HR compensation tool โ€” contains PII, gets full security stack + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Public department listing โ€” standard security only + - tool: list_departments + plugins: + - audit-logger + + # Tool that requires remote authz โ€” triggers the async plugin + # on top of the standard "all" policy stack. The "external_authz" + # tag matches the policy group of the same name, which fires + # remote-authz. + - tool: query_external_data + meta: + tags: [external_authz] + plugins: + - audit-logger + + # Wildcard โ€” catch-all for unmatched tools + - tool: "*" + plugins: + - audit-logger diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs new file mode 100644 index 00000000..12a8ac5e --- /dev/null +++ b/crates/cpex-core/src/cmf/constants.rs @@ -0,0 +1,65 @@ +// Location: ./crates/cpex-core/src/cmf/constants.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF constants โ€” schema version, serialization field names, and defaults. + +/// Current CMF message schema version. +pub const SCHEMA_VERSION: &str = "2.0"; + +// --------------------------------------------------------------------------- +// Serialization field names for MessageView::to_dict() / to_opa_input() +// --------------------------------------------------------------------------- + +// Core view fields +pub const FIELD_KIND: &str = "kind"; +pub const FIELD_ROLE: &str = "role"; +pub const FIELD_IS_PRE: &str = "is_pre"; +pub const FIELD_IS_POST: &str = "is_post"; +pub const FIELD_ACTION: &str = "action"; +pub const FIELD_HOOK: &str = "hook"; +pub const FIELD_URI: &str = "uri"; +pub const FIELD_NAME: &str = "name"; +pub const FIELD_CONTENT: &str = "content"; +pub const FIELD_SIZE_BYTES: &str = "size_bytes"; +pub const FIELD_MIME_TYPE: &str = "mime_type"; +pub const FIELD_ARGUMENTS: &str = "arguments"; + +// Extensions container +pub const FIELD_EXTENSIONS: &str = "extensions"; + +// Subject fields +pub const FIELD_SUBJECT: &str = "subject"; +pub const FIELD_ID: &str = "id"; +pub const FIELD_TYPE: &str = "type"; +pub const FIELD_ROLES: &str = "roles"; +pub const FIELD_PERMISSIONS: &str = "permissions"; +pub const FIELD_TEAMS: &str = "teams"; + +// Security fields +pub const FIELD_LABELS: &str = "labels"; + +// Request fields +pub const FIELD_ENVIRONMENT: &str = "environment"; + +// HTTP fields +pub const FIELD_HEADERS: &str = "headers"; + +// Agent fields +pub const FIELD_AGENT: &str = "agent"; +pub const FIELD_INPUT: &str = "input"; +pub const FIELD_SESSION_ID: &str = "session_id"; +pub const FIELD_CONVERSATION_ID: &str = "conversation_id"; +pub const FIELD_TURN: &str = "turn"; +pub const FIELD_AGENT_ID: &str = "agent_id"; +pub const FIELD_PARENT_AGENT_ID: &str = "parent_agent_id"; + +// Meta fields +pub const FIELD_META: &str = "meta"; +pub const FIELD_ENTITY_TYPE: &str = "entity_type"; +pub const FIELD_ENTITY_NAME: &str = "entity_name"; +pub const FIELD_TAGS: &str = "tags"; + +// OPA envelope +pub const FIELD_OPA_INPUT: &str = "input"; diff --git a/crates/cpex-core/src/cmf/content.rs b/crates/cpex-core/src/cmf/content.rs new file mode 100644 index 00000000..3cde9b65 --- /dev/null +++ b/crates/cpex-core/src/cmf/content.rs @@ -0,0 +1,486 @@ +// Location: ./crates/cpex-core/src/cmf/content.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF domain objects and ContentPart hierarchy. +// +// Domain objects (ToolCall, Resource, etc.) are standalone structs +// reusable outside of message content parts. ContentPart is a tagged +// enum that wraps them for message serialization. +// +// Mirrors the Python types in cpex/framework/cmf/message.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::enums::ResourceType; +use super::message::Message; + +// --------------------------------------------------------------------------- +// Domain Objects +// --------------------------------------------------------------------------- + +/// Normalized tool/function invocation request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + /// Unique request correlation ID. + pub tool_call_id: String, + /// Tool name. + pub name: String, + /// Arguments as a JSON-serializable map. + #[serde(default)] + pub arguments: HashMap, + /// Optional namespace for namespaced tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, +} + +/// Result from tool execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + /// Correlation ID linking to the corresponding tool call. + pub tool_call_id: String, + /// Name of the tool that was executed. + pub tool_name: String, + /// Result content (any JSON-serializable value). + #[serde(default)] + pub content: serde_json::Value, + /// Whether the result represents an error. + #[serde(default)] + pub is_error: bool, +} + +/// Embedded resource with content (MCP). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Resource { + /// Unique request correlation ID. + pub resource_request_id: String, + /// Unique identifier in URI format. + pub uri: String, + /// Human-readable name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// What this resource contains. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The kind of resource. + pub resource_type: ResourceType, + /// Text content if embedded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Binary content if embedded (base64 in JSON). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blob: Option>, + /// MIME type of content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Size information. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + /// Metadata (classification, retention, etc.). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub annotations: HashMap, + /// Version tracking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +impl Resource { + /// Whether content or blob is embedded. + pub fn is_embedded(&self) -> bool { + self.content.is_some() || self.blob.is_some() + } + + /// Get text content if available. + pub fn get_text_content(&self) -> Option<&str> { + self.content.as_deref() + } +} + +/// Lightweight resource reference without embedded content. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceReference { + /// Correlation ID linking to the originating resource request. + pub resource_request_id: String, + /// Resource URI. + pub uri: String, + /// Human-readable name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Type of resource. + pub resource_type: ResourceType, + /// Line number or byte offset for partial references. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_start: Option, + /// End of range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_end: Option, + /// CSS/XPath/JSONPath selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +/// Prompt template invocation request (MCP). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptRequest { + /// Request ID for correlation. + pub prompt_request_id: String, + /// Prompt template name. + pub name: String, + /// Arguments to pass to the template. + #[serde(default)] + pub arguments: HashMap, + /// Source server for multi-server scenarios. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, +} + +/// Rendered prompt template result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptResult { + /// ID of the corresponding prompt request. + pub prompt_request_id: String, + /// Name of the prompt that was rendered. + pub prompt_name: String, + /// Rendered messages (prompts produce messages). + #[serde(default)] + pub messages: Vec, + /// Single text result for simple prompts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Whether rendering failed. + #[serde(default)] + pub is_error: bool, + /// Error details if rendering failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_message: Option, +} + +// --------------------------------------------------------------------------- +// Media Source Types +// --------------------------------------------------------------------------- + +/// Image source data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., image/jpeg). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, +} + +/// Video source data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., video/mp4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Duration in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +/// Audio source data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., audio/mp3). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Duration in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +/// Document source data (PDF, Word, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., application/pdf). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Document title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +// --------------------------------------------------------------------------- +// ContentPart โ€” Tagged Enum +// --------------------------------------------------------------------------- + +/// A typed content part in a CMF message. +/// +/// Discriminated by the `content_type` field. Each variant wraps +/// either a text string or a domain object. +/// +/// Mirrors the Python `ContentPartUnion` discriminated union in +/// `cpex/framework/cmf/message.py`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "content_type")] +pub enum ContentPart { + /// Plain text content. + #[serde(rename = "text")] + Text { text: String }, + + /// Chain-of-thought reasoning. + #[serde(rename = "thinking")] + Thinking { text: String }, + + /// Tool/function invocation request. + #[serde(rename = "tool_call")] + ToolCall { content: ToolCall }, + + /// Result from tool execution. + #[serde(rename = "tool_result")] + ToolResult { content: ToolResult }, + + /// Embedded resource with content. + #[serde(rename = "resource")] + Resource { content: Resource }, + + /// Lightweight resource reference. + #[serde(rename = "resource_ref")] + ResourceRef { content: ResourceReference }, + + /// Prompt template invocation request. + #[serde(rename = "prompt_request")] + PromptRequest { content: PromptRequest }, + + /// Rendered prompt template result. + #[serde(rename = "prompt_result")] + PromptResult { content: PromptResult }, + + /// Image content. + #[serde(rename = "image")] + Image { content: ImageSource }, + + /// Video content. + #[serde(rename = "video")] + Video { content: VideoSource }, + + /// Audio content. + #[serde(rename = "audio")] + Audio { content: AudioSource }, + + /// Document content. + #[serde(rename = "document")] + Document { content: DocumentSource }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_text_content_part_serde() { + let json = r#"{"content_type":"text","text":"Hello, world!"}"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Text { text } => assert_eq!(text, "Hello, world!"), + _ => panic!("expected Text variant"), + } + let roundtrip = serde_json::to_string(&part).unwrap(); + let part2: ContentPart = serde_json::from_str(&roundtrip).unwrap(); + match part2 { + ContentPart::Text { text } => assert_eq!(text, "Hello, world!"), + _ => panic!("expected Text variant"), + } + } + + #[test] + fn test_tool_call_content_part_serde() { + let json = r#"{ + "content_type": "tool_call", + "content": { + "tool_call_id": "tc_001", + "name": "get_weather", + "arguments": {"city": "London"} + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::ToolCall { content } => { + assert_eq!(content.name, "get_weather"); + assert_eq!(content.tool_call_id, "tc_001"); + assert_eq!(content.arguments["city"], "London"); + } + _ => panic!("expected ToolCall variant"), + } + } + + #[test] + fn test_tool_result_content_part_serde() { + let json = r#"{ + "content_type": "tool_result", + "content": { + "tool_call_id": "tc_001", + "tool_name": "get_weather", + "content": {"temp": 20, "unit": "C"}, + "is_error": false + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::ToolResult { content } => { + assert_eq!(content.tool_name, "get_weather"); + assert!(!content.is_error); + } + _ => panic!("expected ToolResult variant"), + } + } + + #[test] + fn test_resource_content_part_serde() { + let json = r#"{ + "content_type": "resource", + "content": { + "resource_request_id": "rr_001", + "uri": "file:///data.txt", + "resource_type": "file", + "content": "Hello from file" + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Resource { content } => { + assert_eq!(content.uri, "file:///data.txt"); + assert!(content.is_embedded()); + assert_eq!(content.get_text_content(), Some("Hello from file")); + } + _ => panic!("expected Resource variant"), + } + } + + #[test] + fn test_resource_ref_content_part_serde() { + let json = r#"{ + "content_type": "resource_ref", + "content": { + "resource_request_id": "rr_002", + "uri": "db://users/42", + "resource_type": "database" + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::ResourceRef { content } => { + assert_eq!(content.uri, "db://users/42"); + assert_eq!(content.resource_type, ResourceType::Database); + } + _ => panic!("expected ResourceRef variant"), + } + } + + #[test] + fn test_image_content_part_serde() { + let json = r#"{ + "content_type": "image", + "content": { + "type": "url", + "data": "https://example.com/photo.jpg", + "media_type": "image/jpeg" + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Image { content } => { + assert_eq!(content.source_type, "url"); + assert_eq!(content.data, "https://example.com/photo.jpg"); + } + _ => panic!("expected Image variant"), + } + } + + #[test] + fn test_prompt_request_content_part_serde() { + let json = r#"{ + "content_type": "prompt_request", + "content": { + "prompt_request_id": "pr_001", + "name": "summarize", + "arguments": {"text": "Long document..."} + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::PromptRequest { content } => { + assert_eq!(content.name, "summarize"); + } + _ => panic!("expected PromptRequest variant"), + } + } + + #[test] + fn test_thinking_content_part_serde() { + let json = r#"{"content_type":"thinking","text":"Let me analyze..."}"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Thinking { text } => assert_eq!(text, "Let me analyze..."), + _ => panic!("expected Thinking variant"), + } + } + + #[test] + fn test_tool_call_construction() { + let tc = ToolCall { + tool_call_id: "tc_001".into(), + name: "search".into(), + arguments: [("query".to_string(), serde_json::json!("rust"))].into(), + namespace: None, + }; + assert_eq!(tc.name, "search"); + assert_eq!(tc.arguments["query"], "rust"); + } + + #[test] + fn test_resource_is_embedded() { + let embedded = Resource { + resource_request_id: "rr_001".into(), + uri: "file:///data.txt".into(), + name: None, + description: None, + resource_type: ResourceType::File, + content: Some("data".into()), + blob: None, + mime_type: None, + size_bytes: None, + annotations: HashMap::new(), + version: None, + }; + assert!(embedded.is_embedded()); + + let not_embedded = Resource { + resource_request_id: "rr_002".into(), + uri: "file:///other.txt".into(), + name: None, + description: None, + resource_type: ResourceType::File, + content: None, + blob: None, + mime_type: None, + size_bytes: None, + annotations: HashMap::new(), + version: None, + }; + assert!(!not_embedded.is_embedded()); + } +} diff --git a/crates/cpex-core/src/cmf/enums.rs b/crates/cpex-core/src/cmf/enums.rs new file mode 100644 index 00000000..97f1dcc2 --- /dev/null +++ b/crates/cpex-core/src/cmf/enums.rs @@ -0,0 +1,176 @@ +// Location: ./crates/cpex-core/src/cmf/enums.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF enums โ€” Role, Channel, ContentType, ResourceType. +// +// Mirrors the Python enums in cpex/framework/cmf/message.py. +// All use snake_case serialization to match Python string values. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Role +// --------------------------------------------------------------------------- + +/// Identifies WHO is speaking in a conversation turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + /// System-level instructions. + System, + /// Developer-provided instructions. + Developer, + /// Human user input. + User, + /// LLM/agent response. + Assistant, + /// Tool execution result. + Tool, +} + +// --------------------------------------------------------------------------- +// Channel +// --------------------------------------------------------------------------- + +/// Classifies the kind of output a message represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Channel { + /// Intermediate analytical output (chain-of-thought). + Analysis, + /// Meta-level observations about the task. + Commentary, + /// Terminal response intended for delivery. + Final, +} + +// --------------------------------------------------------------------------- +// ContentType +// --------------------------------------------------------------------------- + +/// Discriminator for the typed ContentPart hierarchy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentType { + /// Plain text content. + Text, + /// Chain-of-thought reasoning. + Thinking, + /// Tool/function invocation request. + ToolCall, + /// Result from tool execution. + ToolResult, + /// Embedded resource with content (MCP). + Resource, + /// Lightweight resource reference without embedded content. + ResourceRef, + /// Prompt template invocation request (MCP). + PromptRequest, + /// Rendered prompt template result. + PromptResult, + /// Image content (URL or base64). + Image, + /// Video content (URL or base64). + Video, + /// Audio content (URL or base64). + Audio, + /// Document content (PDF, Word, etc.). + Document, +} + +// --------------------------------------------------------------------------- +// ResourceType +// --------------------------------------------------------------------------- + +/// Type of resource being referenced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceType { + /// File-system resource. + #[default] + File, + /// Binary large object. + Blob, + /// Generic URI-addressable resource. + Uri, + /// Database entity. + Database, + /// API endpoint. + Api, + /// In-memory or ephemeral resource. + Memory, + /// Produced artifact (generated output, build result). + Artifact, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_role_serde_roundtrip() { + let role = Role::Assistant; + let json = serde_json::to_string(&role).unwrap(); + assert_eq!(json, "\"assistant\""); + let deserialized: Role = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, Role::Assistant); + } + + #[test] + fn test_channel_serde_roundtrip() { + let channel = Channel::Final; + let json = serde_json::to_string(&channel).unwrap(); + assert_eq!(json, "\"final\""); + let deserialized: Channel = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, Channel::Final); + } + + #[test] + fn test_content_type_serde_roundtrip() { + let ct = ContentType::ToolCall; + let json = serde_json::to_string(&ct).unwrap(); + assert_eq!(json, "\"tool_call\""); + let deserialized: ContentType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, ContentType::ToolCall); + } + + #[test] + fn test_content_type_resource_ref() { + let ct = ContentType::ResourceRef; + let json = serde_json::to_string(&ct).unwrap(); + assert_eq!(json, "\"resource_ref\""); + } + + #[test] + fn test_content_type_prompt_variants() { + let req = ContentType::PromptRequest; + let res = ContentType::PromptResult; + assert_eq!(serde_json::to_string(&req).unwrap(), "\"prompt_request\""); + assert_eq!(serde_json::to_string(&res).unwrap(), "\"prompt_result\""); + } + + #[test] + fn test_resource_type_serde_roundtrip() { + let rt = ResourceType::Database; + let json = serde_json::to_string(&rt).unwrap(); + assert_eq!(json, "\"database\""); + let deserialized: ResourceType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, ResourceType::Database); + } + + #[test] + fn test_all_roles_deserialize() { + for (s, expected) in &[ + ("\"system\"", Role::System), + ("\"developer\"", Role::Developer), + ("\"user\"", Role::User), + ("\"assistant\"", Role::Assistant), + ("\"tool\"", Role::Tool), + ] { + let role: Role = serde_json::from_str(s).unwrap(); + assert_eq!(role, *expected); + } + } +} diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs new file mode 100644 index 00000000..b2bad350 --- /dev/null +++ b/crates/cpex-core/src/cmf/message.rs @@ -0,0 +1,460 @@ +// Location: ./crates/cpex-core/src/cmf/message.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF Message โ€” canonical message representation. +// +// A Message is the storage and wire format for a single turn in a +// conversation. It preserves structure exactly as the LLM or +// framework sent it. +// +// Extensions are NOT part of the Message. They are passed separately +// to handlers via the framework's Extensions type. This allows +// extensions to be shared across payload types and avoids copying +// the message when extensions change. +// +// Mirrors the Python Message in cpex/framework/cmf/message.py. + +use serde::{Deserialize, Serialize}; + +use super::content::*; +use super::enums::{Channel, Role}; +use crate::hooks::trait_def::PluginResult; + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +/// Canonical CMF message representing a single turn in a conversation. +/// +/// All content is carried as typed ContentPart variants. Extensions +/// (identity, security, HTTP, agent context) are passed separately +/// to handlers โ€” not inside the message. +/// +/// Mirrors the Python `Message` in `cpex/framework/cmf/message.py`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + /// Message schema version. + #[serde(default = "default_schema_version")] + pub schema_version: String, + + /// Who is speaking. + pub role: Role, + + /// List of typed content parts (multimodal). + #[serde(default)] + pub content: Vec, + + /// Optional output classification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +fn default_schema_version() -> String { + super::constants::SCHEMA_VERSION.to_string() +} + +impl Message { + /// Create a simple text message. + pub fn text(role: Role, text: impl Into) -> Self { + Self { + schema_version: super::constants::SCHEMA_VERSION.to_string(), + role, + content: vec![ContentPart::Text { text: text.into() }], + channel: None, + } + } + + /// Extract all text content from the message. + /// + /// Concatenates text from all `Text` content parts. + pub fn get_text_content(&self) -> String { + let mut texts = Vec::new(); + for part in &self.content { + if let ContentPart::Text { text } = part { + texts.push(text.as_str()); + } + } + texts.join("") + } + + /// Extract thinking/reasoning content if present. + pub fn get_thinking_content(&self) -> Option { + let mut texts = Vec::new(); + for part in &self.content { + if let ContentPart::Thinking { text } = part { + texts.push(text.as_str()); + } + } + if texts.is_empty() { + None + } else { + Some(texts.join("")) + } + } + + /// Get all tool calls in this message. + pub fn get_tool_calls(&self) -> Vec<&ToolCall> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::ToolCall { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all tool results in this message. + pub fn get_tool_results(&self) -> Vec<&ToolResult> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::ToolResult { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Whether this message contains any tool calls. + pub fn is_tool_call(&self) -> bool { + self.content + .iter() + .any(|p| matches!(p, ContentPart::ToolCall { .. })) + } + + /// Whether this message contains any tool results. + pub fn is_tool_result(&self) -> bool { + self.content + .iter() + .any(|p| matches!(p, ContentPart::ToolResult { .. })) + } + + /// Get all embedded resources in this message. + pub fn get_resources(&self) -> Vec<&Resource> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::Resource { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all resource references in this message. + pub fn get_resource_refs(&self) -> Vec<&ResourceReference> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::ResourceRef { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all resource URIs (both embedded and references). + pub fn get_all_resource_uris(&self) -> Vec<&str> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::Resource { content } => Some(content.uri.as_str()), + ContentPart::ResourceRef { content } => Some(content.uri.as_str()), + _ => None, + }) + .collect() + } + + /// Whether this message contains any resources or resource references. + pub fn has_resources(&self) -> bool { + self.content.iter().any(|p| { + matches!( + p, + ContentPart::Resource { .. } | ContentPart::ResourceRef { .. } + ) + }) + } + + /// Get all prompt requests in this message. + pub fn get_prompt_requests(&self) -> Vec<&PromptRequest> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::PromptRequest { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all prompt results in this message. + pub fn get_prompt_results(&self) -> Vec<&PromptResult> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::PromptResult { content } => Some(content), + _ => None, + }) + .collect() + } +} + +// --------------------------------------------------------------------------- +// MessagePayload โ€” PluginPayload wrapper +// --------------------------------------------------------------------------- + +/// CMF Message wrapped as a PluginPayload for hook dispatch. +/// +/// This is the payload type for all `cmf.*` hooks. Plugins that +/// handle CMF hooks implement `HookHandler` and receive +/// `&MessagePayload` in their handler. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessagePayload { + /// The CMF message. + pub message: Message, +} + +crate::impl_plugin_payload!(MessagePayload); + +// --------------------------------------------------------------------------- +// CmfHook โ€” Hook Type Definition +// --------------------------------------------------------------------------- + +crate::define_hook! { + /// CMF message evaluation hook. + /// + /// Plugins implement `HookHandler` and register under + /// one or more `cmf.*` hook names (e.g., `cmf.tool_pre_invoke`, + /// `cmf.llm_input`). The same handler covers all CMF hook points. + CmfHook, "cmf" => { + payload: MessagePayload, + result: PluginResult, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::payload::PluginPayload; + use crate::hooks::trait_def::HookTypeDef; + + #[test] + fn test_message_text_helper() { + let msg = Message::text(Role::User, "What is the weather?"); + assert_eq!(msg.get_text_content(), "What is the weather?"); + assert_eq!(msg.role, Role::User); + assert_eq!(msg.schema_version, "2.0"); + } + + #[test] + fn test_message_multi_part_text() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Hello ".into(), + }, + ContentPart::Text { + text: "world!".into(), + }, + ], + channel: None, + }; + assert_eq!(msg.get_text_content(), "Hello world!"); + } + + #[test] + fn test_message_thinking_content() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Thinking { + text: "Let me think...".into(), + }, + ContentPart::Text { + text: "Here's my answer.".into(), + }, + ], + channel: Some(Channel::Final), + }; + assert_eq!( + msg.get_thinking_content(), + Some("Let me think...".to_string()) + ); + assert_eq!(msg.get_text_content(), "Here's my answer."); + } + + #[test] + fn test_message_tool_calls() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Let me check.".into(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_weather".into(), + arguments: [("city".to_string(), serde_json::json!("London"))].into(), + namespace: None, + }, + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_002".into(), + name: "get_time".into(), + arguments: [("timezone".to_string(), serde_json::json!("UTC"))].into(), + namespace: None, + }, + }, + ], + channel: None, + }; + assert!(msg.is_tool_call()); + assert!(!msg.is_tool_result()); + let calls = msg.get_tool_calls(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "get_weather"); + assert_eq!(calls[1].name, "get_time"); + } + + #[test] + fn test_message_tool_results() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Tool, + content: vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_weather".into(), + content: serde_json::json!({"temp": 20}), + is_error: false, + }, + }], + channel: None, + }; + assert!(msg.is_tool_result()); + assert!(!msg.is_tool_call()); + let results = msg.get_tool_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_name, "get_weather"); + } + + #[test] + fn test_message_resources() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Resource { + content: Resource { + resource_request_id: "rr_001".into(), + uri: "file:///data.txt".into(), + name: Some("Data File".into()), + description: None, + resource_type: super::super::enums::ResourceType::File, + content: Some("file contents".into()), + blob: None, + mime_type: None, + size_bytes: None, + annotations: std::collections::HashMap::new(), + version: None, + }, + }, + ContentPart::ResourceRef { + content: ResourceReference { + resource_request_id: "rr_002".into(), + uri: "db://users/42".into(), + name: None, + resource_type: super::super::enums::ResourceType::Database, + range_start: None, + range_end: None, + selector: None, + }, + }, + ], + channel: None, + }; + assert!(msg.has_resources()); + assert_eq!(msg.get_resources().len(), 1); + assert_eq!(msg.get_resource_refs().len(), 1); + let uris = msg.get_all_resource_uris(); + assert_eq!(uris.len(), 2); + assert!(uris.contains(&"file:///data.txt")); + assert!(uris.contains(&"db://users/42")); + } + + #[test] + fn test_message_no_resources() { + let msg = Message::text(Role::User, "Hello"); + assert!(!msg.has_resources()); + assert!(msg.get_resources().is_empty()); + } + + #[test] + fn test_message_serde_roundtrip() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Thinking { + text: "Analyzing...".into(), + }, + ContentPart::Text { + text: "Here's the answer.".into(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "search".into(), + arguments: [("q".to_string(), serde_json::json!("rust"))].into(), + namespace: None, + }, + }, + ], + channel: Some(Channel::Final), + }; + + let json = serde_json::to_string(&msg).unwrap(); + let deserialized: Message = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.role, Role::Assistant); + assert_eq!(deserialized.schema_version, "2.0"); + assert_eq!(deserialized.channel, Some(Channel::Final)); + assert_eq!(deserialized.content.len(), 3); + assert_eq!(deserialized.get_text_content(), "Here's the answer."); + assert_eq!(deserialized.get_tool_calls().len(), 1); + } + + #[test] + fn test_message_payload_as_plugin_payload() { + let payload = MessagePayload { + message: Message::text(Role::User, "Hello"), + }; + + // Test clone_boxed + let boxed: Box = Box::new(payload.clone()); + let cloned = boxed.clone_boxed(); + + // Test as_any downcast + let downcasted = cloned + .as_any() + .downcast_ref::() + .expect("should downcast to MessagePayload"); + assert_eq!(downcasted.message.get_text_content(), "Hello"); + } + + #[test] + fn test_cmf_hook_type_def() { + assert_eq!(CmfHook::NAME, "cmf"); + } + + #[test] + fn test_message_default_schema_version() { + let json = r#"{"role":"user","content":[]}"#; + let msg: Message = serde_json::from_str(json).unwrap(); + assert_eq!(msg.schema_version, "2.0"); + } +} diff --git a/crates/cpex-core/src/cmf/mod.rs b/crates/cpex-core/src/cmf/mod.rs new file mode 100644 index 00000000..11ae76a4 --- /dev/null +++ b/crates/cpex-core/src/cmf/mod.rs @@ -0,0 +1,100 @@ +// Location: ./crates/cpex-core/src/cmf/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// ContextForge Message Format (CMF). +// +// Canonical message representation for interactions between users, +// agents, tools, and language models. All models mirror the Python +// CMF in cpex/framework/cmf/message.py. +// +// Extensions are NOT part of the Message โ€” they are passed separately +// to handlers via the framework's Extensions type in hooks/payload.rs. +// This allows extensions to be shared across payload types and avoids +// copying the message when extensions change. +// +// # Hook Registration Patterns +// +// CMF supports two registration patterns for plugins: +// +// ## Pattern 1: One handler, multiple hook names (recommended) +// +// Use `CmfHook` as the hook type and register under multiple names. +// The plugin writes one handler that covers all CMF hooks. The host +// invokes via `invoke_by_name("cmf.tool_pre_invoke", ...)`. +// +// ```rust,ignore +// // Plugin implements one handler: +// impl HookHandler for MyPlugin { +// fn handle(&self, payload: &MessagePayload, ext: &Extensions, ctx: &mut PluginContext) +// -> PluginResult { ... } +// } +// +// // Factory registers under multiple names: +// PluginInstance { +// plugin: plugin.clone(), +// handlers: vec![ +// ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), +// ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), +// ], +// } +// +// // Host invokes via invoke_named โ€” compile-time payload type safety +// // plus runtime hook name routing: +// mgr.invoke_named::( +// "cmf.tool_pre_invoke", payload, ext, None, +// ).await; +// ``` +// +// `invoke_named::(hook_name, ...)` gives you both: +// - **Compile-time**: payload must be `MessagePayload` (from `CmfHook::Payload`) +// - **Runtime**: dispatches to plugins registered under the specific hook name +// +// This is the recommended approach for CMF hooks. Alternatively, use +// `invoke_by_name(hook_name, boxed_payload, ...)` for fully dynamic +// dispatch (no compile-time payload check). +// +// ## Pattern 2: Individual hook types (optional) +// +// For hosts that want per-hook marker types, define separate hook +// types. Each maps to one hook name. The plugin must implement a +// handler per type (more boilerplate). +// +// ```rust,ignore +// define_hook! { +// CmfToolPreInvoke, "cmf.tool_pre_invoke" => { +// payload: MessagePayload, +// result: PluginResult, +// } +// } +// +// // Plugin implements per-hook handlers: +// impl HookHandler for MyPlugin { ... } +// impl HookHandler for MyPlugin { ... } +// +// // Host uses typed invoke: +// mgr.invoke::(payload, ext, None).await; +// ``` +// +// Both patterns use the same executor, registry, and capabilities. +// Pattern 1 with `invoke_named` is recommended โ€” one handler impl, +// compile-time payload safety, and explicit hook name routing. +// +// Available CMF hook names (defined in hooks/types.rs): +// cmf.tool_pre_invoke, cmf.tool_post_invoke, +// cmf.llm_input, cmf.llm_output, +// cmf.prompt_pre_fetch, cmf.prompt_post_fetch, +// cmf.resource_pre_fetch, cmf.resource_post_fetch + +pub mod constants; +pub mod content; +pub mod enums; +pub mod message; +pub mod view; + +// Re-export key types at the cmf module level +pub use content::*; +pub use enums::*; +pub use message::{CmfHook, Message, MessagePayload}; +pub use view::{MessageView, ViewAction, ViewKind}; diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs new file mode 100644 index 00000000..2c92e76d --- /dev/null +++ b/crates/cpex-core/src/cmf/view.rs @@ -0,0 +1,864 @@ +// 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.is_some_and(|h| h.contains("pre")) + } + + /// Whether this is a post-execution hook. + pub fn is_post(&self) -> bool { + self.hook.is_some_and(|h| h.contains("post")) + } + + // -- Universal properties -- + + /// Text content (for text, thinking, tool result content). + pub fn content(&self) -> Option<&str> { + match self.part { + ContentPart::Text { text } | ContentPart::Thinking { text } => Some(text), + ContentPart::ToolResult { content: tr } => { + tr.content.as_str().map(Some).unwrap_or(None) + } + ContentPart::Resource { content: r } => r.content.as_deref(), + ContentPart::PromptResult { content: pr } => pr.content.as_deref(), + _ => None, + } + } + + /// Entity name (tool name, resource URI, prompt name). + pub fn name(&self) -> Option<&str> { + match self.part { + ContentPart::ToolCall { content: tc } => Some(&tc.name), + ContentPart::ToolResult { content: tr } => Some(&tr.tool_name), + ContentPart::Resource { content: r } => r.name.as_deref().or(Some(&r.uri)), + ContentPart::ResourceRef { content: rr } => rr.name.as_deref().or(Some(&rr.uri)), + ContentPart::PromptRequest { content: pr } => Some(&pr.name), + ContentPart::PromptResult { content: pr } => Some(&pr.prompt_name), + _ => None, + } + } + + /// URI for the entity. + pub fn uri(&self) -> Option { + match self.part { + ContentPart::ToolCall { content: tc } => Some(format!("tool://_/{}", tc.name)), + ContentPart::Resource { content: r } => Some(r.uri.clone()), + ContentPart::ResourceRef { content: rr } => Some(rr.uri.clone()), + ContentPart::PromptRequest { content: pr } => Some(format!("prompt://_/{}", pr.name)), + _ => None, + } + } + + /// Arguments (for tool calls and prompt requests). + pub fn args(&self) -> Option<&std::collections::HashMap> { + match self.part { + ContentPart::ToolCall { content: tc } => Some(&tc.arguments), + ContentPart::PromptRequest { content: pr } => Some(&pr.arguments), + _ => None, + } + } + + /// Get a specific argument by name. + pub fn get_arg(&self, name: &str) -> Option<&serde_json::Value> { + self.args().and_then(|a| a.get(name)) + } + + /// Whether this content has arguments. + pub fn has_arg(&self, name: &str) -> bool { + self.get_arg(name).is_some() + } + + /// MIME type (for resources, media). + pub fn mime_type(&self) -> Option<&str> { + match self.part { + ContentPart::Resource { content: r } => r.mime_type.as_deref(), + ContentPart::Image { content: img } => img.media_type.as_deref(), + ContentPart::Video { content: vid } => vid.media_type.as_deref(), + ContentPart::Audio { content: aud } => aud.media_type.as_deref(), + ContentPart::Document { content: doc } => doc.media_type.as_deref(), + _ => None, + } + } + + /// Whether the result is an error (tool results, prompt results). + pub fn is_error(&self) -> bool { + match self.part { + ContentPart::ToolResult { content: tr } => tr.is_error, + ContentPart::PromptResult { content: pr } => pr.is_error, + _ => false, + } + } + + // -- Type helpers -- + + pub fn is_tool(&self) -> bool { + self.kind.is_tool() + } + pub fn is_resource(&self) -> bool { + self.kind.is_resource() + } + pub fn is_prompt(&self) -> bool { + self.kind.is_prompt() + } + pub fn is_media(&self) -> bool { + self.kind.is_media() + } + pub fn is_text(&self) -> bool { + self.kind.is_text() + } + + // -- Extension accessors -- + + /// Get the extensions, if provided. + pub fn extensions(&self) -> Option<&'a Extensions> { + self.extensions + } + + /// Check if a security label exists. + pub fn has_label(&self, label: &str) -> bool { + self.extensions + .and_then(|e| e.security.as_ref()) + .map(|s| s.has_label(label)) + .unwrap_or(false) + } + + /// Get an HTTP header value. + pub fn get_header(&self, name: &str) -> Option<&str> { + self.extensions + .and_then(|e| e.http.as_ref()) + .and_then(|h| h.get_header(name)) + } + + // -- Serialization -- + + /// Sensitive headers stripped during serialization. + const SENSITIVE_HEADERS: &'static [&'static str] = &["authorization", "cookie", "x-api-key"]; + + /// Serialize the view to a JSON-compatible map. + /// + /// Includes the view's properties, arguments, and optionally + /// text content and extension context. Sensitive headers + /// (Authorization, Cookie, X-API-Key) are stripped. + pub fn to_dict(&self, include_content: bool, include_context: bool) -> serde_json::Value { + use super::constants::*; + + let mut result = serde_json::Map::new(); + + // Core fields + result.insert(FIELD_KIND.into(), serde_json::json!(self.kind)); + result.insert(FIELD_ROLE.into(), serde_json::json!(self.role)); + result.insert(FIELD_IS_PRE.into(), serde_json::json!(self.is_pre())); + result.insert(FIELD_IS_POST.into(), serde_json::json!(self.is_post())); + result.insert(FIELD_ACTION.into(), serde_json::json!(self.action())); + + if let Some(hook) = self.hook { + result.insert(FIELD_HOOK.into(), serde_json::json!(hook)); + } + + if let Some(uri) = self.uri() { + result.insert(FIELD_URI.into(), serde_json::json!(uri)); + } + + if let Some(name) = self.name() { + result.insert(FIELD_NAME.into(), serde_json::json!(name)); + } + + // Content + if include_content { + if let Some(text) = self.content() { + result.insert(FIELD_SIZE_BYTES.into(), serde_json::json!(text.len())); + result.insert(FIELD_CONTENT.into(), serde_json::json!(text)); + } + } + + if let Some(mime) = self.mime_type() { + result.insert(FIELD_MIME_TYPE.into(), serde_json::json!(mime)); + } + + // Arguments + if let Some(args) = self.args() { + result.insert(FIELD_ARGUMENTS.into(), serde_json::json!(args)); + } + + // Extensions context + if include_context { + if let Some(ext) = self.extensions { + let mut ext_map = serde_json::Map::new(); + + // Subject + if let Some(ref sec) = ext.security { + if let Some(ref subject) = sec.subject { + let mut sub_map = serde_json::Map::new(); + if let Some(ref id) = subject.id { + sub_map.insert(FIELD_ID.into(), serde_json::json!(id)); + } + if let Some(ref st) = subject.subject_type { + sub_map.insert(FIELD_TYPE.into(), serde_json::json!(st)); + } + if !subject.roles.is_empty() { + let mut roles: Vec<&String> = subject.roles.iter().collect(); + roles.sort(); + sub_map.insert(FIELD_ROLES.into(), serde_json::json!(roles)); + } + if !subject.permissions.is_empty() { + let mut perms: Vec<&String> = subject.permissions.iter().collect(); + perms.sort(); + sub_map.insert(FIELD_PERMISSIONS.into(), serde_json::json!(perms)); + } + if !subject.teams.is_empty() { + let mut teams: Vec<&String> = subject.teams.iter().collect(); + teams.sort(); + sub_map.insert(FIELD_TEAMS.into(), serde_json::json!(teams)); + } + if !sub_map.is_empty() { + ext_map + .insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); + } + } + + // Labels + if !sec.labels.is_empty() { + let mut labels: Vec<&String> = sec.labels.iter().collect(); + labels.sort(); + ext_map.insert(FIELD_LABELS.into(), serde_json::json!(labels)); + } + } + + // Environment + if let Some(ref req) = ext.request { + if let Some(ref env) = req.environment { + ext_map.insert(FIELD_ENVIRONMENT.into(), serde_json::json!(env)); + } + } + + // Request headers (strip sensitive) + if let Some(ref http) = ext.http { + let safe: std::collections::HashMap<&String, &String> = http + .request_headers + .iter() + .filter(|(k, _)| { + !Self::SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) + }) + .collect(); + if !safe.is_empty() { + ext_map.insert(FIELD_HEADERS.into(), serde_json::json!(safe)); + } + } + + // Agent context + if let Some(ref agent) = ext.agent { + let mut agent_map = serde_json::Map::new(); + if let Some(ref input) = agent.input { + agent_map.insert(FIELD_INPUT.into(), serde_json::json!(input)); + } + if let Some(ref sid) = agent.session_id { + agent_map.insert(FIELD_SESSION_ID.into(), serde_json::json!(sid)); + } + if let Some(ref cid) = agent.conversation_id { + agent_map.insert(FIELD_CONVERSATION_ID.into(), serde_json::json!(cid)); + } + if let Some(turn) = agent.turn { + agent_map.insert(FIELD_TURN.into(), serde_json::json!(turn)); + } + if let Some(ref aid) = agent.agent_id { + agent_map.insert(FIELD_AGENT_ID.into(), serde_json::json!(aid)); + } + if let Some(ref paid) = agent.parent_agent_id { + agent_map.insert(FIELD_PARENT_AGENT_ID.into(), serde_json::json!(paid)); + } + if !agent_map.is_empty() { + ext_map.insert(FIELD_AGENT.into(), serde_json::Value::Object(agent_map)); + } + } + + // Meta + if let Some(ref meta) = ext.meta { + let mut meta_map = serde_json::Map::new(); + if let Some(ref et) = meta.entity_type { + meta_map.insert(FIELD_ENTITY_TYPE.into(), serde_json::json!(et)); + } + if let Some(ref en) = meta.entity_name { + meta_map.insert(FIELD_ENTITY_NAME.into(), serde_json::json!(en)); + } + if !meta.tags.is_empty() { + let mut tags: Vec<&String> = meta.tags.iter().collect(); + tags.sort(); + meta_map.insert(FIELD_TAGS.into(), serde_json::json!(tags)); + } + if !meta_map.is_empty() { + ext_map.insert(FIELD_META.into(), serde_json::Value::Object(meta_map)); + } + } + + if !ext_map.is_empty() { + result.insert(FIELD_EXTENSIONS.into(), serde_json::Value::Object(ext_map)); + } + } + } + + serde_json::Value::Object(result) + } + + /// Serialize to OPA-compatible input format. + /// + /// Wraps the view in the standard OPA input envelope: + /// `{"input": {...view data...}}`. + pub fn to_opa_input(&self, include_content: bool) -> serde_json::Value { + use super::constants::FIELD_OPA_INPUT; + serde_json::json!({ + FIELD_OPA_INPUT: self.to_dict(include_content, true) + }) + } +} + +impl<'a> std::fmt::Debug for MessageView<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MessageView") + .field("kind", &self.kind) + .field("role", &self.role) + .field("name", &self.name()) + .field("hook", &self.hook) + .finish() + } +} + +// --------------------------------------------------------------------------- +// 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::{HttpExtension, SecurityExtension}; + use std::sync::Arc; + + let mut security = SecurityExtension::default(); + security.add_label("PII"); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer tok"); + + let ext = Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + ..Default::default() + }; + + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect(); + + assert!(views[0].has_label("PII")); + assert!(!views[0].has_label("HIPAA")); + assert_eq!(views[0].get_header("Authorization"), Some("Bearer tok")); + } + + #[test] + fn test_to_dict_basic() { + let msg = Message::text(Role::User, "Hello world"); + let views: Vec<_> = msg.iter_views(Some("llm_input"), None).collect(); + let dict = views[0].to_dict(true, false); + + assert_eq!(dict["kind"], "text"); + assert_eq!(dict["role"], "user"); + assert_eq!(dict["action"], "send"); + assert_eq!(dict["hook"], "llm_input"); + assert_eq!(dict["content"], "Hello world"); + assert_eq!(dict["size_bytes"], 11); + assert_eq!(dict["is_pre"], false); + assert_eq!(dict["is_post"], false); + } + + #[test] + fn test_to_dict_tool_call() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect(); + let dict = views[2].to_dict(true, false); // tool call + + assert_eq!(dict["kind"], "tool_call"); + assert_eq!(dict["name"], "get_weather"); + assert_eq!(dict["uri"], "tool://_/get_weather"); + assert_eq!(dict["action"], "execute"); + assert_eq!(dict["is_pre"], true); + assert!(dict["arguments"].is_object()); + assert_eq!(dict["arguments"]["city"], "London"); + } + + #[test] + fn test_to_dict_without_content() { + let msg = Message::text(Role::User, "Secret message"); + let views: Vec<_> = msg.iter_views(None, None).collect(); + let dict = views[0].to_dict(false, false); + + assert!(dict.get("content").is_none()); + assert!(dict.get("size_bytes").is_none()); + } + + #[test] + fn test_to_dict_with_extensions() { + use crate::extensions::{ + AgentExtension, HttpExtension, RequestExtension, SecurityExtension, + }; + use std::sync::Arc; + + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.subject = Some(crate::extensions::security::SubjectExtension { + id: Some("alice".into()), + subject_type: Some(crate::extensions::security::SubjectType::User), + roles: ["admin".to_string()].into(), + ..Default::default() + }); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer secret"); + http.set_header("X-Request-ID", "req-123"); + + let ext = Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + request: Some(Arc::new(RequestExtension { + environment: Some("production".into()), + ..Default::default() + })), + agent: Some(Arc::new(AgentExtension { + session_id: Some("sess-001".into()), + agent_id: Some("agent-x".into()), + ..Default::default() + })), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: ["pii".to_string()].into(), + ..Default::default() + })), + ..Default::default() + }; + + let msg = Message::text(Role::User, "test"); + let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect(); + let dict = views[0].to_dict(true, true); + + let extensions = &dict["extensions"]; + + // Subject visible + assert_eq!(extensions["subject"]["id"], "alice"); + assert!(extensions["subject"]["roles"] + .as_array() + .unwrap() + .contains(&serde_json::json!("admin"))); + + // Labels visible + assert!(extensions["labels"] + .as_array() + .unwrap() + .contains(&serde_json::json!("PII"))); + + // Environment visible + assert_eq!(extensions["environment"], "production"); + + // Headers visible โ€” but Authorization stripped (sensitive) + assert!(extensions["headers"].get("Authorization").is_none()); + assert_eq!(extensions["headers"]["X-Request-ID"], "req-123"); + + // Agent context visible + assert_eq!(extensions["agent"]["session_id"], "sess-001"); + assert_eq!(extensions["agent"]["agent_id"], "agent-x"); + + // Meta visible + assert_eq!(extensions["meta"]["entity_type"], "tool"); + assert_eq!(extensions["meta"]["entity_name"], "get_compensation"); + } + + #[test] + fn test_to_opa_input() { + let msg = Message::text(Role::User, "Hello"); + let views: Vec<_> = msg.iter_views(None, None).collect(); + let opa = views[0].to_opa_input(true); + + assert!(opa.get("input").is_some()); + assert_eq!(opa["input"]["kind"], "text"); + assert_eq!(opa["input"]["role"], "user"); + assert_eq!(opa["input"]["content"], "Hello"); + } +} diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs new file mode 100644 index 00000000..89d962a2 --- /dev/null +++ b/crates/cpex-core/src/config.rs @@ -0,0 +1,1297 @@ +// Location: ./crates/cpex-core/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Unified YAML configuration parsing. +// +// Parses the config format that combines global settings, plugin +// declarations, and per-entity routes into a single YAML document. +// +// Supports two modes controlled by `plugin_settings.routing_enabled`: +// - false (default, backward compatible): plugins declare their +// own conditions for when they fire. +// - true: per-entity routing rules determine which plugins fire, +// with plugin selection via policy groups and meta.tags. +// +// The two modes are mutually exclusive. When routing is disabled, +// the routes and global sections are ignored. When routing is +// enabled, conditions on individual plugins are ignored. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::error::PluginError; +use crate::plugin::PluginConfig; + +// --------------------------------------------------------------------------- +// Top-Level 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, + + /// Maximum number of entries in the routing cache. + /// + /// When the cache reaches this size, new resolutions are computed + /// normally but not memoized โ€” the cache rejects further inserts + /// and emits a warning. This bounds memory growth from + /// attacker-controlled entity names without the reasoning hazards + /// of eviction (silently dropped entries, stale-vs-current + /// confusion). Operators see the warning and tune the cap or + /// investigate the entity-name growth. + #[serde(default = "default_route_cache_max_entries")] + pub route_cache_max_entries: usize, +} + +impl Default for PluginSettings { + fn default() -> Self { + Self { + routing_enabled: false, + plugin_timeout: 30, + short_circuit_on_deny: true, + parallel_execution_within_band: false, + fail_on_plugin_error: false, + route_cache_max_entries: default_route_cache_max_entries(), + } + } +} + +fn default_route_cache_max_entries() -> usize { + 10_000 +} + +fn default_timeout() -> u64 { + 30 +} + +fn default_true() -> bool { + true +} + +// --------------------------------------------------------------------------- +// Global 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 PluginRouteRef { + /// Just the name โ€” activate the plugin with no config overrides. + Name(String), + /// Name with config overrides โ€” single-key map. + WithOverrides(HashMap), +} + +impl PluginRouteRef { + /// Extract the plugin name from this reference. + pub fn name(&self) -> &str { + match self { + Self::Name(name) => name, + Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""), + } + } + + /// Extract config overrides, if any. + pub fn overrides(&self) -> Option<&serde_json::Value> { + match self { + Self::Name(_) => None, + Self::WithOverrides(map) => map.values().next(), + } + } +} + +// --------------------------------------------------------------------------- +// Route Entry +// --------------------------------------------------------------------------- + +/// A per-entity routing rule. +/// +/// Matches one entity type (tool, resource, prompt, or LLM) and +/// determines which plugins fire. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteEntry { + /// Match a tool by exact name, list, or glob. + #[serde(default)] + pub tool: Option, + + /// Match a resource by exact URI, list, or glob. + #[serde(default)] + pub resource: Option, + + /// Match a prompt by exact name, list, or glob. + #[serde(default)] + pub prompt: Option, + + /// Match an LLM by exact model name, list, or glob. + #[serde(default)] + pub llm: Option, + + /// Operational metadata โ€” tags, scope, properties. + #[serde(default)] + pub meta: Option, + + /// Conditional match expression โ€” carried but not evaluated + /// during static resolution. Evaluated at runtime when payload + /// data is available (future: APL evaluator). + #[serde(default)] + pub when: Option, + + /// Plugin references to activate for this route. + #[serde(default)] + pub plugins: Vec, +} + +// --------------------------------------------------------------------------- +// Route Meta +// --------------------------------------------------------------------------- + +/// Operational metadata on a route entry. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteMeta { + /// Entity tags โ€” drive policy group inheritance. + #[serde(default)] + pub tags: Vec, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + /// Used for scope matching: route scope must match request scope. + #[serde(default)] + pub scope: Option, + + /// Arbitrary key-value metadata. + #[serde(default)] + pub properties: HashMap, +} + +// --------------------------------------------------------------------------- +// String or List (for tool matching) +// --------------------------------------------------------------------------- + +/// An entity-name pattern. Holds the original pattern string (for +/// serialization round-tripping and operator-facing diagnostics) plus a +/// `WildMatch` matcher pre-compiled at deserialize time so route resolution +/// doesn't re-parse the pattern on every request. Custom `Serialize` / +/// `Deserialize` make this transparent to YAML โ€” it serializes as a plain +/// string, just like the previous `String` field did. +/// +/// Glob syntax (via `wildmatch`): +/// - `*` matches any sequence of characters (including empty). +/// - `?` matches any single character. +/// +/// The previous hand-rolled matcher only handled trailing-`*` correctly: +/// `*suffix` patterns silently matched almost nothing, and multi-star +/// patterns like `**` accidentally matched everything. Both shapes are +/// real security footguns for scope/tool restriction rules โ€” switching to +/// `wildmatch` gives us full single-segment glob semantics. +#[derive(Debug, Clone)] +pub struct Pattern { + pattern: String, + matcher: wildmatch::WildMatch, +} + +impl Pattern { + /// Compile a pattern. Done once at config load; subsequent `matches()` + /// calls reuse the compiled `WildMatch`. + pub fn new(pattern: impl Into) -> Self { + let pattern = pattern.into(); + let matcher = wildmatch::WildMatch::new(&pattern); + Self { pattern, matcher } + } + + /// Match the given name against the compiled pattern. + pub fn matches(&self, name: &str) -> bool { + self.matcher.matches(name) + } + + /// The original pattern string (e.g., `"hr-*"`). + pub fn as_str(&self) -> &str { + &self.pattern + } +} + +impl Default for Pattern { + fn default() -> Self { + Self::new("") + } +} + +impl Serialize for Pattern { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.pattern) + } +} + +impl<'de> Deserialize<'de> for Pattern { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Pattern::new(s)) + } +} + +/// A tool matcher โ€” single name, list of names, or glob pattern. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StringOrList { + /// Single string (exact name or glob pattern). Pre-compiled at + /// deserialize time so the route-resolution slow path doesn't re-parse + /// on each request. + Single(Pattern), + /// List of exact names. + List(Vec), +} + +impl Default for StringOrList { + fn default() -> Self { + Self::Single(Pattern::default()) + } +} + +impl StringOrList { + /// Check if this matcher matches the given name. + pub fn matches(&self, name: &str) -> bool { + match self { + Self::Single(pattern) => pattern.matches(name), + Self::List(names) => names.iter().any(|n| n == name), + } + } +} + +// --------------------------------------------------------------------------- +// 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<(), Box> { + let mut seen_names = HashSet::new(); + for plugin in &config.plugins { + if !seen_names.insert(&plugin.name) { + return Err(Box::new(PluginError::Config { + message: format!("duplicate plugin name: '{}'", plugin.name), + })); + } + } + + if config.routing_enabled() { + let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect(); + + for (i, route) in config.routes.iter().enumerate() { + let count = [ + route.tool.is_some(), + route.resource.is_some(), + route.prompt.is_some(), + route.llm.is_some(), + ] + .iter() + .filter(|&&m| m) + .count(); + + if count == 0 { + return Err(Box::new(PluginError::Config { + message: format!( + "route {} has no entity matcher (need tool, resource, prompt, or llm)", + i + ), + })); + } + if count > 1 { + return Err(Box::new(PluginError::Config { + message: format!( + "route {} has multiple entity matchers (need exactly one)", + i + ), + })); + } + + for plugin_ref in &route.plugins { + if !plugin_names.contains(plugin_ref.name()) { + return Err(Box::new(PluginError::Config { + message: format!( + "route {} references unknown plugin '{}'", + i, + plugin_ref.name() + ), + })); + } + } + } + + for (group_name, group) in &config.global.policies { + for plugin_ref in &group.plugins { + if !plugin_names.contains(plugin_ref.name()) { + return Err(Box::new(PluginError::Config { + message: format!( + "policy group '{}' references unknown plugin '{}'", + group_name, + plugin_ref.name() + ), + })); + } + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Route Resolution +// --------------------------------------------------------------------------- + +/// Specificity scores for route matching. +const SPECIFICITY_EXACT_NAME: usize = 1000; +const SPECIFICITY_NAME_LIST: usize = 500; +const SPECIFICITY_GLOB: usize = 300; +const SPECIFICITY_WHEN_ONLY: usize = 10; +const SPECIFICITY_WILDCARD: usize = 0; + +/// Score a single entity matcher (tool / resource / prompt / llm) against +/// a request entity name, returning the specificity bucket if it matches +/// or `None` if it doesn't (or the matcher is absent). Replaces four +/// copy-pasted match arms in `resolve_plugins_for_entity`. +fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option { + let matcher = matcher?; + if !matcher.matches(entity_name) { + return None; + } + let score = match matcher { + StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + }; + Some(score) +} + +/// Resolve which plugins should fire for a given entity. +/// +/// When routing is disabled, returns all plugin names. When enabled, +/// 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: &[PluginRouteRef], + resolved: &mut Vec, + route_when: Option<&str>, +) { + for plugin_ref in refs { + resolved.push(ResolvedPlugin { + name: plugin_ref.name().to_string(), + config_overrides: plugin_ref.overrides().cloned(), + when: route_when.map(String::from), + }); + } +} + +/// Find the best matching route for an entity by specificity. +/// +/// Scope matching: if a route declares a scope, the request must +/// have the same scope. No scope on the route matches any request. +fn find_matching_route<'a>( + config: &'a CpexConfig, + entity_type: &str, + entity_name: &str, + request_scope: Option<&str>, +) -> Option<&'a RouteEntry> { + let mut best: Option<(usize, &RouteEntry)> = None; + + for route in &config.routes { + // 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 entity_matcher = match entity_type { + "tool" => route.tool.as_ref(), + "resource" => route.resource.as_ref(), + "prompt" => route.prompt.as_ref(), + "llm" => route.llm.as_ref(), + _ => continue, + }; + let base_specificity = match score_entity_match(entity_matcher, entity_name) { + Some(score) => score, + None => continue, + }; + + let when_bonus = if route.when.is_some() { + SPECIFICITY_WHEN_ONLY + } else { + 0 + }; + let total = base_specificity + scope_bonus + when_bonus; + + if best.is_none_or(|(s, _)| total > s) { + best = Some((total, route)); + } + } + + best.map(|(_, route)| route) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helper: empty tags for tests that don't need them + fn no_tags() -> HashSet { + HashSet::new() + } + + #[test] + fn test_parse_minimal_config() { + let yaml = r#" +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] + mode: sequential + priority: 5 + config: + max_requests: 100 +"#; + let config = parse_config(yaml).unwrap(); + assert!(!config.routing_enabled()); + assert_eq!(config.plugins.len(), 1); + assert_eq!(config.plugins[0].name, "rate_limiter"); + } + + #[test] + fn test_no_plugin_settings_defaults_routing_disabled() { + let yaml = r#" +plugins: + - name: test + kind: builtin + hooks: [tool_pre_invoke] +"#; + let config = parse_config(yaml).unwrap(); + assert!(!config.routing_enabled()); + assert_eq!(config.plugin_settings.plugin_timeout, 30); + } + + #[test] + fn test_routing_enabled() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] +plugins: + - name: identity + kind: builtin + hooks: [identity_resolve] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + assert!(config.routing_enabled()); + } + + #[test] + fn test_duplicate_plugin_names_rejected() { + let yaml = r#" +plugins: + - name: dup + kind: builtin + hooks: [tool_pre_invoke] + - name: dup + kind: builtin + hooks: [tool_post_invoke] +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("duplicate plugin name")); + } + + #[test] + fn test_route_requires_one_entity_matcher() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: [] +routes: + - meta: + tags: [pii] +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("no entity matcher")); + } + + #[test] + fn test_route_rejects_multiple_entity_matchers() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: [] +routes: + - tool: get_compensation + resource: "hr://employees/*" +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("multiple entity matchers")); + } + + #[test] + fn test_route_unknown_plugin_rejected() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: known + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + plugins: + - unknown +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("unknown plugin 'unknown'")); + } + + #[test] + fn test_policy_group_unknown_plugin_rejected() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [nonexistent] +plugins: [] +routes: [] +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("unknown plugin 'nonexistent'")); + } + + #[test] + fn test_resolve_conditions_mode_returns_all() { + let yaml = r#" +plugins: + - name: a + kind: builtin + hooks: [tool_pre_invoke] + - name: b + kind: builtin + hooks: [tool_post_invoke] +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b"]); + } + + #[test] + fn test_resolve_routes_inherits_policy_groups() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: + - identity + pii: + plugins: + - apl_policy +plugins: + - name: identity + kind: builtin + hooks: [identity_resolve] + - name: apl_policy + kind: builtin + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"identity")); + assert!(names.contains(&"apl_policy")); + } + + #[test] + fn test_resolve_no_matching_route_gets_all_only() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: + - identity +plugins: + - name: identity + kind: builtin + hooks: [identity_resolve] +routes: + - tool: get_compensation +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["identity"]); + } + + #[test] + fn test_exact_match_beats_glob() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: specific + kind: builtin + hooks: [tool_pre_invoke] + - name: general + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: "hr-*" + plugins: + - general + - tool: hr-compensation + plugins: + - specific +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"specific")); + assert!(!names.contains(&"general")); + } + + #[test] + fn test_plugin_ref_bare_name() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + plugins: + - rate_limiter +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved[0].name, "rate_limiter"); + assert!(resolved[0].config_overrides.is_none()); + } + + #[test] + fn test_plugin_ref_with_overrides() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] + config: + max_requests: 100 +routes: + - tool: get_compensation + plugins: + - rate_limiter: + config: + max_requests: 10 +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved[0].name, "rate_limiter"); + assert!(resolved[0].config_overrides.is_some()); + let overrides = resolved[0].config_overrides.as_ref().unwrap(); + assert_eq!(overrides["config"]["max_requests"], 10); + } + + #[test] + fn test_plugin_ref_mixed_bare_and_overrides() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] + - name: pii_scanner + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + plugins: + - rate_limiter + - pii_scanner: + config: + sensitivity: high +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].name, "rate_limiter"); + assert!(resolved[0].config_overrides.is_none()); + assert_eq!(resolved[1].name, "pii_scanner"); + assert!(resolved[1].config_overrides.is_some()); + } + + #[test] + fn test_deduplication_preserves_order() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [a, b] + pii: + plugins: [b, c] +plugins: + - name: a + kind: builtin + hooks: [tool_pre_invoke] + - name: b + kind: builtin + hooks: [tool_pre_invoke] + - name: c + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b", "c"]); + } + + #[test] + fn test_glob_trailing_wildcard() { + let matcher = StringOrList::Single(Pattern::new("hr-*")); + assert!(matcher.matches("hr-compensation")); + assert!(matcher.matches("hr-benefits")); + assert!(matcher.matches("hr-")); // empty match for * + assert!(!matcher.matches("finance-report")); + assert!(!matcher.matches("hr")); + } + + #[test] + fn test_wildcard_matches_everything() { + let matcher = StringOrList::Single(Pattern::new("*")); + assert!(matcher.matches("anything")); + assert!(matcher.matches("")); + } + + /// Regression for the security footgun: `*suffix` patterns were + /// silently matching almost nothing because the previous matcher + /// looked for `"*suffix"` as a literal prefix. + #[test] + fn test_glob_leading_wildcard() { + let matcher = StringOrList::Single(Pattern::new("*-prod")); + assert!(matcher.matches("foo-prod")); + assert!(matcher.matches("-prod")); // empty match for * + assert!(!matcher.matches("foo-staging")); + assert!(!matcher.matches("prod")); + } + + /// Regression for `prefix*suffix` patterns also broken before. + #[test] + fn test_glob_mid_wildcard() { + let matcher = StringOrList::Single(Pattern::new("hr-*-v1")); + assert!(matcher.matches("hr-comp-v1")); + assert!(matcher.matches("hr--v1")); // empty match for * + assert!(!matcher.matches("hr-comp-v2")); + assert!(!matcher.matches("finance-comp-v1")); + } + + /// Multiple-wildcard patterns must work everywhere `*` appears. + #[test] + fn test_glob_multiple_wildcards() { + let matcher = StringOrList::Single(Pattern::new("*hr*comp*")); + assert!(matcher.matches("hr-comp")); + assert!(matcher.matches("xyz-hr-comp-foo")); + assert!(!matcher.matches("hr-only")); + assert!(!matcher.matches("comp-only")); + } + + /// Regression for the OTHER security footgun: multi-star patterns + /// like `**` were `trim_end_matches('*')`'d to `""` and then matched + /// every name via `starts_with("")`. With wildmatch this is a + /// degenerate-but-correct "match anything" pattern, equivalent to `*`. + #[test] + fn test_glob_multi_star_is_equivalent_to_single_star() { + for pattern in &["**", "***", "*****"] { + let matcher = StringOrList::Single(Pattern::new(*pattern)); + assert!( + matcher.matches("anything"), + "pattern {} should match", + pattern + ); + assert!( + matcher.matches(""), + "pattern {} should match empty", + pattern + ); + } + } + + /// `WildMatch` is built once at deserialize / `Pattern::new` time and + /// reused; this test just sanity-checks the round-trip through serde. + #[test] + fn test_pattern_round_trips_through_yaml() { + let yaml = "tool: '*-prod'"; + #[derive(Deserialize, Serialize)] + struct Wrap { + tool: StringOrList, + } + let parsed: Wrap = serde_yaml::from_str(yaml).unwrap(); + assert!(parsed.tool.matches("foo-prod")); + assert!(!parsed.tool.matches("foo-staging")); + let back = serde_yaml::to_string(&parsed).unwrap(); + assert!( + back.contains("*-prod"), + "serialized YAML should preserve pattern: {}", + back + ); + } + + #[test] + fn test_list_matches_any_member() { + let matcher = StringOrList::List(vec![ + "get_compensation".to_string(), + "get_benefits".to_string(), + ]); + assert!(matcher.matches("get_compensation")); + assert!(matcher.matches("get_benefits")); + assert!(!matcher.matches("send_email")); + } + + #[test] + fn test_validation_skipped_when_routing_disabled() { + let yaml = r#" +plugins: + - name: test + kind: builtin + hooks: [tool_pre_invoke] +routes: + - meta: + tags: [pii] +"#; + let config = parse_config(yaml); + assert!(config.is_ok()); + } + + // -- Scope matching tests -- + + #[test] + fn test_scope_match_selects_scoped_route() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: scoped_plugin + kind: builtin + hooks: [tool_pre_invoke] + - name: global_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + meta: + scope: hr-services + plugins: + - scoped_plugin + - tool: get_compensation + plugins: + - global_plugin +"#; + let config = parse_config(yaml).unwrap(); + + // With matching scope โ€” scoped route wins (more specific) + let resolved = resolve_plugins_for_entity( + &config, + "tool", + "get_compensation", + Some("hr-services"), + &no_tags(), + ); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"scoped_plugin")); + assert!(!names.contains(&"global_plugin")); + + // Without scope โ€” global route matches + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"global_plugin")); + assert!(!names.contains(&"scoped_plugin")); + + // With different scope โ€” global route matches (scoped doesn't) + let resolved = resolve_plugins_for_entity( + &config, + "tool", + "get_compensation", + Some("billing"), + &no_tags(), + ); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"global_plugin")); + assert!(!names.contains(&"scoped_plugin")); + } + + // -- Tag merging tests -- + + #[test] + fn test_host_tags_merged_with_route_tags() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + pii: + plugins: [pii_plugin] + runtime_tag: + plugins: [runtime_plugin] +plugins: + - name: pii_plugin + kind: builtin + hooks: [tool_pre_invoke] + - name: runtime_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + + // Host provides a runtime tag that matches a policy group + let mut host_tags = HashSet::new(); + host_tags.insert("runtime_tag".to_string()); + + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + + // Both route's static tag (pii) and host's runtime tag activate their groups + assert!(names.contains(&"pii_plugin")); + assert!(names.contains(&"runtime_plugin")); + } + + // -- When clause carried tests -- + + #[test] + fn test_when_clause_carried_on_resolved_plugins() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: conditional_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + when: "args.include_ssn == true" + plugins: + - conditional_plugin +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved[0].name, "conditional_plugin"); + assert_eq!( + resolved[0].when.as_deref(), + Some("args.include_ssn == true") + ); + } + + #[test] + fn test_when_clause_not_on_policy_group_plugins() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [global_plugin] +plugins: + - name: global_plugin + kind: builtin + hooks: [tool_pre_invoke] + - name: route_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + when: "args.sensitive == true" + plugins: + - route_plugin +"#; + let config = parse_config(yaml).unwrap(); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + + // global_plugin has no when clause (from all group) + let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap(); + assert!(global.when.is_none()); + + // route_plugin carries the route's when clause + let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap(); + assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); + } +} diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs new file mode 100644 index 00000000..2176c7bc --- /dev/null +++ b/crates/cpex-core/src/context.rs @@ -0,0 +1,195 @@ +// Location: ./crates/cpex-core/src/context.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Execution context types. +// +// Provides PluginContext โ€” the per-plugin, per-invocation execution +// context carrying transient state (counters, caches, intermediate +// results). All data needed for policy evaluation comes from the +// payload's extensions (filtered by capabilities), not from context. +// +// PluginContext has two state maps: +// - local_state: private to this plugin, this invocation +// - global_state: shared across plugins in a pipeline +// +// Identity, request metadata, tenant scope, etc. live in extensions +// (MetaExtension, SecurityExtension), not in the context. +// +// Mirrors the spec's PluginContext in plugin-framework-spec-v2.md ยง8.1. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Plugin Context +// --------------------------------------------------------------------------- + +/// Per-plugin, per-invocation execution context. +/// +/// Each plugin receives its own `PluginContext` with: +/// +/// - `local_state` โ€” private to this plugin, this invocation. Fresh +/// each time. Used for per-plugin counters, caches, scratch data. +/// - `global_state` โ€” shared across all plugins in a pipeline. The +/// executor merges changes back after serial phases so subsequent +/// plugins see contributions from earlier ones. +/// +/// All data needed for policy evaluation (identity, tenant, request +/// metadata) comes from the payload's extensions, capability-gated +/// per plugin. Context is purely for transient execution state. +/// +/// ```text +/// PluginContext +/// โ”œโ”€โ”€ local_state: HashMap # Per-plugin, per-request. Private. +/// โ””โ”€โ”€ global_state: HashMap # Shared across plugins. Use with care. +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginContext { + /// Plugin-local state. Private to this plugin, this invocation. + #[serde(default)] + pub local_state: HashMap, + + /// Shared state across all plugins in the pipeline. + /// The executor merges changes back after each serial-phase plugin. + #[serde(default)] + pub global_state: HashMap, +} + +impl PluginContext { + /// Create a new empty plugin context. + pub fn new() -> Self { + Self { + local_state: HashMap::new(), + global_state: HashMap::new(), + } + } + + /// Create a plugin context with pre-populated global state. + pub fn with_global_state(global_state: HashMap) -> Self { + Self { + local_state: HashMap::new(), + global_state, + } + } + + /// Get a value from local state. + pub fn get_local(&self, key: &str) -> Option<&Value> { + self.local_state.get(key) + } + + /// Set a value in local state. + pub fn set_local(&mut self, key: impl Into, value: Value) { + self.local_state.insert(key.into(), value); + } + + /// Get a value from global state. + pub fn get_global(&self, key: &str) -> Option<&Value> { + self.global_state.get(key) + } + + /// Set a value in global state. + pub fn set_global(&mut self, key: impl Into, value: Value) { + self.global_state.insert(key.into(), value); + } +} + +impl Default for PluginContext { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Plugin Context Table +// --------------------------------------------------------------------------- + +/// Threaded execution state carried from one hook invocation to the next +/// within a single request lifecycle (e.g., `pre_invoke` โ†’ `post_invoke`). +/// +/// The table holds the canonical pipeline state in two parts: +/// +/// - `global_state` โ€” a single shared map across all plugins. The executor +/// clones this into each plugin's `PluginContext.global_state` at the +/// start of a run, then commits the plugin's possibly-modified copy back +/// when the run completes (last-writer-wins for serial phases). +/// - `local_states` โ€” per-plugin private state, indexed by plugin ID. +/// Persists across hook invocations so a plugin's `pre_invoke` can stash +/// data its `post_invoke` will read. +/// +/// Storing `global_state` once (rather than copying it inside every per-plugin +/// `PluginContext`) makes the canonical state explicit and removes the +/// non-deterministic "pick an arbitrary plugin's snapshot" pattern that was +/// previously needed to recover it. +/// +/// Returned by the executor in `PipelineResult` and passed back into the +/// next hook call. On the first hook call pass `None` โ€” the executor +/// creates a fresh table. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct PluginContextTable { + /// Authoritative shared state across all plugins in the pipeline. + #[serde(default)] + pub global_state: HashMap, + + /// Per-plugin local state, indexed by plugin ID (`Uuid`). + #[serde(default)] + pub local_states: HashMap>, +} + +impl PluginContextTable { + /// Create an empty context table. + pub fn new() -> Self { + Self::default() + } + + /// Build a `PluginContext` for the given plugin, *removing* its stored + /// local_state from the table and seeding it with a fresh clone of the + /// canonical global_state. Use in serial phases where the plugin will + /// commit its local_state changes back via [`store_context`]. + /// + /// If the plugin has no stored local_state yet, its context starts + /// empty (first invocation in the request lifecycle). + pub fn take_context(&mut self, plugin_id: Uuid) -> PluginContext { + PluginContext { + local_state: self.local_states.remove(&plugin_id).unwrap_or_default(), + global_state: self.global_state.clone(), + } + } + + /// Build a `PluginContext` for the given plugin without mutating the + /// table โ€” the local_state is *cloned* and the global_state is cloned. + /// Use in read-only phases (audit, concurrent, fire-and-forget) where + /// per-plugin mutations should not influence subsequent plugins. + pub fn snapshot_context(&self, plugin_id: Uuid) -> PluginContext { + PluginContext { + local_state: self + .local_states + .get(&plugin_id) + .cloned() + .unwrap_or_default(), + global_state: self.global_state.clone(), + } + } + + /// Commit a plugin's context back into the table after it ran. Replaces + /// the canonical global_state with the plugin's possibly-modified copy + /// (move, no clone) and stores the plugin's local_state for next time. + pub fn store_context(&mut self, plugin_id: Uuid, ctx: PluginContext) { + self.global_state = ctx.global_state; + self.local_states.insert(plugin_id, ctx.local_state); + } + + /// Number of plugins with stored local_state in the table. + pub fn len(&self) -> usize { + self.local_states.len() + } + + /// Whether the table holds no per-plugin local_state. + pub fn is_empty(&self) -> bool { + self.local_states.is_empty() + } +} diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs new file mode 100644 index 00000000..576dc5bc --- /dev/null +++ b/crates/cpex-core/src/error.rs @@ -0,0 +1,275 @@ +// 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. +/// +/// Mirrors the Python framework's `PluginErrorModel` with: +/// - `code` โ€” business-logic error code (e.g., `"rate_limit_exceeded"`) +/// - `details` โ€” structured diagnostic data for logging +/// - `proto_error_code` โ€” protocol-level error code for the host to +/// map back to the wire format (MCP JSON-RPC, HTTP status, etc.) +#[derive(Debug, Error)] +pub enum PluginError { + /// A plugin raised an execution error. + #[error("plugin '{plugin_name}' failed: {message}")] + Execution { + plugin_name: String, + message: String, + /// Business-logic error code (e.g., `"invalid_token"`). + #[source] + source: Option>, + /// Business-logic error code set by the plugin. + code: Option, + /// Structured diagnostic data for logging or debugging. + details: HashMap, + /// Protocol-level error code for the host to map to the wire + /// format. MCP: JSON-RPC codes (e.g., -32603). HTTP: status + /// codes. The host interprets this; CPEX just carries it. + proto_error_code: Option, + }, + + /// A plugin exceeded its execution timeout. + #[error("plugin '{plugin_name}' timed out after {timeout_ms}ms")] + Timeout { + plugin_name: String, + timeout_ms: u64, + /// Protocol-level error code for the host. + proto_error_code: Option, + }, + + /// A plugin returned a policy violation (deny). + #[error("plugin '{plugin_name}' denied: {}", violation.reason)] + Violation { + plugin_name: String, + violation: PluginViolation, + }, + + /// Configuration parsing or validation failed. + #[error("configuration error: {message}")] + Config { message: String }, + + /// A hook type was not found in the registry. + #[error("unknown hook type: {hook_type}")] + UnknownHook { hook_type: String }, +} + +impl PluginError { + /// Box this error for use in `Result>`. + /// + /// Public APIs return `Result>` rather than + /// `Result>` because the enum is large (~184 bytes + /// โ€” `details: HashMap` and the `source: Box` push it + /// well past clippy's `result_large_err` threshold). Boxing keeps + /// `Result` pointer-sized on the success path; the + /// allocation only happens on the error path. + /// + /// `.boxed()` is sugar for `Box::new(...)` that reads better at + /// construction sites: `PluginError::Config { ... }.boxed()`. + /// `?` already calls `From::from`, and `From for Box` is + /// built into std, so existing `?` chains keep working. + pub fn boxed(self) -> Box { + Box::new(self) + } +} + +// --------------------------------------------------------------------------- +// Plugin Error Record +// --------------------------------------------------------------------------- + +/// A `Clone`-able, serialization-friendly snapshot of a `PluginError`. +/// +/// Used in `PipelineResult.errors` to surface execution failures from +/// `on_error: ignore` / `on_error: disable` plugins to the caller โ€” +/// previously those errors were only logged via `tracing::warn!` and +/// were invisible to programmatic consumers (agents, dashboards, +/// retry logic). +/// +/// `PluginError` itself can't be `Clone` because of its +/// `Box` source field, and that +/// field doesn't survive serialization anyway. `PluginErrorRecord` +/// flattens the five enum variants into a single shape โ€” the +/// `From<&PluginError>` impl handles the variant-to-fields mapping. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginErrorRecord { + pub plugin_name: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub details: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proto_error_code: Option, +} + +/// Forward `&Box` to the `&PluginError` impl. +/// +/// Public APIs return `Result>` (see +/// `PluginError::boxed`), which means error-handling code in the +/// pipeline (e.g., `Ok(Err(e))` inside `executor::run_*_phase`) holds +/// `e: Box`. This blanket forward keeps existing +/// `(&e).into()` call sites working without forcing every caller to +/// write `(&*e).into()` after the boxing migration. +impl From<&Box> for PluginErrorRecord { + fn from(e: &Box) -> Self { + PluginErrorRecord::from(e.as_ref()) + } +} + +impl From<&PluginError> for PluginErrorRecord { + fn from(e: &PluginError) -> Self { + match e { + PluginError::Execution { + plugin_name, + message, + code, + details, + proto_error_code, + .. + } => Self { + plugin_name: plugin_name.clone(), + message: message.clone(), + code: code.clone(), + details: details.clone(), + proto_error_code: *proto_error_code, + }, + PluginError::Timeout { + plugin_name, + timeout_ms, + proto_error_code, + } => Self { + plugin_name: plugin_name.clone(), + message: format!("plugin timed out after {}ms", timeout_ms), + code: Some("timeout".into()), + details: HashMap::new(), + proto_error_code: *proto_error_code, + }, + PluginError::Violation { + plugin_name, + violation, + } => Self { + plugin_name: plugin_name.clone(), + message: format!("plugin denied: {}", violation.reason), + code: Some(violation.code.clone()), + details: violation.details.clone(), + proto_error_code: violation.proto_error_code, + }, + PluginError::Config { message } => Self { + plugin_name: String::new(), + message: message.clone(), + code: Some("config".into()), + details: HashMap::new(), + proto_error_code: None, + }, + PluginError::UnknownHook { hook_type } => Self { + plugin_name: String::new(), + message: format!("unknown hook type: {}", hook_type), + code: Some("unknown_hook".into()), + details: HashMap::new(), + proto_error_code: None, + }, + } + } +} + +// --------------------------------------------------------------------------- +// Plugin Violations +// --------------------------------------------------------------------------- + +/// Structured policy violation returned by a plugin that denies execution. +/// +/// Carries a machine-readable code, human-readable reason, and optional +/// diagnostic details. Corresponds to the Python `PluginViolation` type. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::error::PluginViolation; +/// +/// let v = PluginViolation::new("missing_permission", "User lacks pii_access"); +/// assert_eq!(v.code, "missing_permission"); +/// assert_eq!(v.reason, "User lacks pii_access"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginViolation { + /// Machine-readable violation identifier (e.g., `"missing_permission"`). + pub code: String, + + /// Short human-readable reason for the denial. + pub reason: String, + + /// Optional detailed explanation. + pub description: Option, + + /// Structured diagnostic data for logging or debugging. + pub details: HashMap, + + /// Name of the plugin that produced the violation. + /// Set by the framework after the plugin returns, not by the plugin itself. + pub plugin_name: Option, + + /// Protocol-level error code for the host to map to the wire format. + /// MCP: JSON-RPC codes (e.g., -32603). HTTP: status codes (e.g., 403). + /// Set by the plugin; the host interprets it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proto_error_code: Option, +} + +impl PluginViolation { + /// Create a new violation with a code and reason. + pub fn new(code: impl Into, reason: impl Into) -> Self { + Self { + code: code.into(), + reason: reason.into(), + description: None, + details: HashMap::new(), + plugin_name: None, + proto_error_code: None, + } + } + + /// Attach a detailed description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Attach structured diagnostic details. + pub fn with_details(mut self, details: HashMap) -> Self { + self.details = details; + self + } + + /// Attach a protocol-level error code. + pub fn with_proto_error_code(mut self, code: i64) -> Self { + self.proto_error_code = Some(code); + self + } +} + +impl std::fmt::Display for PluginViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.code, self.reason) + } +} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs new file mode 100644 index 00000000..ddf4247a --- /dev/null +++ b/crates/cpex-core/src/executor.rs @@ -0,0 +1,1138 @@ +// Location: ./crates/cpex-core/src/executor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 5-phase plugin execution engine. +// +// Dispatches plugins in strict phase order: +// SEQUENTIAL โ†’ TRANSFORM โ†’ AUDIT โ†’ CONCURRENT โ†’ FIRE_AND_FORGET +// +// Each phase has different authority (block/modify) and scheduling +// (serial/parallel/background). The executor reads all scheduling +// decisions from PluginRef.trusted_config โ€” never from the plugin. +// +// Extensions are passed separately from the payload and capability- +// filtered per plugin before dispatch. Extension modifications are +// merged back independently from payload modifications. +// +// Error handling respects the plugin's on_error setting: +// - Fail: propagate error, halt pipeline +// - Ignore: log error, continue pipeline +// - Disable: log error, mark plugin disabled, continue +// +// Mirrors the Python framework's PluginExecutor in +// cpex/framework/manager.py. + +use std::any::Any; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::timeout; +use tracing::{error, warn}; + +use crate::context::PluginContextTable; +use crate::error::PluginError; +use crate::extensions::filter_extensions; +use crate::hooks::payload::{Extensions, PluginPayload, WriteToken}; +use crate::plugin::OnError; +use crate::registry::{group_by_mode, HookEntry}; + +// --------------------------------------------------------------------------- +// Executor Configuration +// --------------------------------------------------------------------------- + +/// Configuration for the executor. +#[derive(Debug, Clone)] +pub struct ExecutorConfig { + /// Maximum execution time per plugin in seconds. + pub timeout_seconds: u64, + + /// Whether to halt on the first deny in concurrent mode. + pub short_circuit_on_deny: bool, +} + +impl Default for ExecutorConfig { + fn default() -> Self { + Self { + timeout_seconds: 30, + short_circuit_on_deny: true, + } + } +} + +// --------------------------------------------------------------------------- +// Pipeline Result +// --------------------------------------------------------------------------- + +/// Aggregate result from a full hook invocation across all phases. +/// +/// Wraps the final payload, extensions, any violation, and the +/// context table. 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 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 modified_payload: Option>, + + /// The final extensions after all modifications. + /// `None` if no plugin modified extensions. + pub modified_extensions: Option, + + /// The violation that caused a deny, if any. + pub violation: Option, + + /// Errors from plugins that ran with `on_error: ignore` or + /// `on_error: disable`. These plugins didn't halt the pipeline + /// (their on_error policy said to continue), but the caller + /// should still know the errors happened so it can log them in + /// a structured way, retry the affected plugin, or alert. + /// Empty when no plugin errored on a non-halt path. + /// Fire-and-forget errors live in `BackgroundTasks` instead. + pub errors: Vec, + + /// Optional metadata aggregated from plugins (telemetry, diagnostics). + pub metadata: Option, + + /// Plugin contexts indexed by plugin ID. Thread this into the + /// next hook invocation to preserve per-plugin `local_state`. + pub context_table: PluginContextTable, +} + +impl PipelineResult { + /// Pipeline completed โ€” all plugins allowed. + pub fn allowed_with( + payload: Box, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { + Self { + continue_processing: true, + modified_payload: Some(payload), + modified_extensions: Some(extensions), + violation: None, + errors: Vec::new(), + metadata: None, + context_table, + } + } + + /// Pipeline was denied by a plugin. + pub fn denied( + violation: crate::error::PluginViolation, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { + Self { + continue_processing: false, + modified_payload: None, + modified_extensions: Some(extensions), + violation: Some(violation), + errors: Vec::new(), + metadata: None, + context_table, + } + } + + /// Replace the errors vec on a constructed PipelineResult. Used by + /// the executor to attach errors collected from `on_error: ignore` + /// / `on_error: disable` plugins. + pub fn with_errors(mut self, errors: Vec) -> Self { + self.errors = errors; + self + } + + /// Whether this result represents a denial. + pub fn is_denied(&self) -> bool { + !self.continue_processing + } +} + +// --------------------------------------------------------------------------- +// Background Tasks +// --------------------------------------------------------------------------- + +/// Handles to fire-and-forget background tasks spawned by the executor. +/// +/// Returned separately from [`PipelineResult`] so that the policy +/// result stays immutable. If not awaited, tasks complete on their +/// own in the background. Call `wait_for_background_tasks()` when you +/// need to ensure tasks have finished (tests, graceful shutdown, +/// audit flush). +pub struct BackgroundTasks { + tasks: Vec<(String, tokio::task::JoinHandle<()>)>, +} + +impl BackgroundTasks { + /// Create an empty set of background tasks. + pub fn empty() -> Self { + Self { tasks: Vec::new() } + } + + /// Create from a list of (plugin_name, handle) pairs. + fn from_handles(tasks: Vec<(String, tokio::task::JoinHandle<()>)>) -> Self { + Self { tasks } + } + + /// Whether there are any background tasks. + pub fn is_empty(&self) -> bool { + self.tasks.is_empty() + } + + /// Number of background tasks. + pub fn len(&self) -> usize { + self.tasks.len() + } + + /// Wait for all fire-and-forget background tasks to complete. + /// + /// Returns a list of errors from any tasks that panicked. + /// An empty list means all tasks completed successfully. + /// + /// Consumes `self` โ€” each task handle can only be awaited once. + /// + /// If not called, background tasks still complete on their own. + /// Use this for tests, graceful shutdown, or when you need to + /// ensure audit/logging tasks have flushed before proceeding. + pub async fn wait_for_background_tasks(self) -> Vec { + let mut errors = Vec::new(); + for (plugin_name, handle) in self.tasks { + if let Err(e) = handle.await { + errors.push(crate::error::PluginError::Execution { + plugin_name, + message: format!("background task panicked: {}", e), + source: None, + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, + }); + } + } + errors + } +} + +impl fmt::Debug for BackgroundTasks { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BackgroundTasks") + .field("count", &self.tasks.len()) + .finish() + } +} + +// --------------------------------------------------------------------------- +// 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. +#[derive(Clone)] +pub struct Executor { + config: ExecutorConfig, +} + +impl Executor { + /// Create a new executor with the given configuration. + pub fn new(config: ExecutorConfig) -> Self { + Self { config } + } + + /// Execute a hook invocation through the 5-phase pipeline. + /// + /// # Arguments + /// + /// * `entries` โ€” HookEntries for this hook, sorted by priority. + /// * `payload` โ€” The typed payload (type-erased as Box). + /// * `extensions` โ€” The full extensions (filtered per plugin before dispatch). + /// * `context_table` โ€” Optional context table from a previous hook invocation. + /// If `None`, fresh contexts are created for each plugin. + /// + /// # Returns + /// + /// A tuple of: + /// - `PipelineResult` โ€” immutable policy result with payload, + /// extensions, violation, and context table. + /// - `BackgroundTasks` โ€” handles to fire-and-forget tasks. Call + /// `wait_for_background_tasks()` to await them, or drop to let + /// them complete in the background. + pub async fn execute( + &self, + entries: &[HookEntry], + payload: Box, + extensions: Extensions, + context_table: Option, + task_tracker: &tokio_util::task::TaskTracker, + ) -> (PipelineResult, BackgroundTasks) { + let mut ctx_table = context_table.unwrap_or_default(); + + if entries.is_empty() { + return ( + PipelineResult::allowed_with(payload, extensions, ctx_table), + BackgroundTasks::empty(), + ); + } + + // Group entries by mode (from trusted_config) + let (sequential, transform, audit, concurrent, fire_and_forget) = group_by_mode(entries); + + let mut current_payload = payload; + let mut current_extensions = extensions; + // Accumulator for errors from `on_error: ignore` / `on_error: + // disable` plugins across all phases. Surfaced to the caller + // via `PipelineResult.errors` so swallowed failures stay + // observable. Halt-condition errors (Fail, deny) skip this and + // become the violation directly. + let mut errors: Vec = Vec::new(); + + // Phase 1: SEQUENTIAL โ€” serial, chained, can block + modify + if let Some(v) = self + .run_serial_phase( + &sequential, + &mut current_payload, + &mut current_extensions, + &mut ctx_table, + true, // can_block + true, // can_modify + "SEQUENTIAL", + &mut errors, + ) + .await + { + return ( + PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors), + BackgroundTasks::empty(), + ); + } + + // Phase 2: TRANSFORM โ€” serial, chained, can modify, cannot block + // can_block=false means denials are suppressed (returns None) + self.run_serial_phase( + &transform, + &mut current_payload, + &mut current_extensions, + &mut ctx_table, + false, // can_block + true, // can_modify + "TRANSFORM", + &mut errors, + ) + .await; + + // Phase 3: AUDIT โ€” serial, read-only, discard results + self.run_ref_phase( + &audit, + &*current_payload, + ¤t_extensions, + &ctx_table, + "AUDIT", + &mut errors, + ) + .await; + + // Phase 4: CONCURRENT โ€” parallel, can block, cannot modify + if let Some(violation) = self + .run_concurrent_phase( + &concurrent, + &*current_payload, + ¤t_extensions, + &ctx_table, + &mut errors, + ) + .await + { + return ( + PipelineResult::denied(violation, current_extensions, ctx_table) + .with_errors(errors), + BackgroundTasks::empty(), + ); + } + + // Phase 5: FIRE_AND_FORGET โ€” background, read-only, ignore results. + // FAF errors don't go in PipelineResult.errors โ€” they're delivered + // via BackgroundTasks::wait_for_background_tasks() instead. + let bg_handles = self.spawn_fire_and_forget( + &fire_and_forget, + &*current_payload, + ¤t_extensions, + &ctx_table, + task_tracker, + ); + + ( + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + .with_errors(errors), + BackgroundTasks::from_handles(bg_handles), + ) + } + + // ----------------------------------------------------------------------- + // Phase 1 & 2: Serial execution (SEQUENTIAL / TRANSFORM) + // ----------------------------------------------------------------------- + + /// Run a serial phase โ€” plugins execute one at a time, each seeing + /// the (possibly modified) payload from the previous. + /// + /// The framework retains ownership of the payload. Handlers receive + /// a borrow and clone only if they modify. Modified payloads in + /// the result replace the current payload. + /// + /// Each plugin's context is looked up in the context table (preserving + /// `local_state` from previous hooks) or created fresh. After execution, + /// `global_state` changes are merged back so the next plugin sees them. + #[allow(clippy::too_many_arguments)] // internal phase helper โ€” args have distinct types and meaning + async fn run_serial_phase( + &self, + entries: &[HookEntry], + payload: &mut Box, + extensions: &mut Extensions, + ctx_table: &mut PluginContextTable, + can_block: bool, + can_modify: bool, + phase_label: &str, + errors: &mut Vec, + ) -> Option { + for entry in entries { + // Borrow names/ids on the happy path โ€” allocate only when + // building a violation or stashing the local_state back into + // the table. Previously `name.to_string()` + `id.to_string()` + // ran unconditionally on every plugin per invoke. + let plugin_name = entry.plugin_ref.name(); + let plugin_id = entry.plugin_ref.id(); + let on_error = entry.plugin_ref.trusted_config().on_error; + + // Take this plugin's context out of the table โ€” pulls its stored + // local_state and seeds global_state from the canonical store. + // Replaces the previous values().last() seed, which was + // non-deterministic across HashMap iteration orders. + let mut ctx = ctx_table.take_context(plugin_id); + + // Filter extensions per plugin based on declared capabilities. + // Produces a filtered view with None for ungated slots. + // Also sets write tokens for plugins with write capabilities. + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let mut filtered = filter_extensions(extensions, &capabilities); + + // Set write tokens based on capabilities + if capabilities.contains("write_headers") { + filtered.http_write_token = Some(WriteToken::new()); + } + if capabilities.contains("append_labels") { + filtered.labels_write_token = Some(WriteToken::new()); + } + if capabilities.contains("append_delegation") { + filtered.delegation_write_token = Some(WriteToken::new()); + } + + // Execute with timeout โ€” handler borrows payload, gets filtered extensions + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + let result = timeout( + timeout_dur, + entry.handler.invoke(&**payload, &filtered, &mut ctx), + ) + .await; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + // Check deny + if !erased.continue_processing && can_block { + if let Some(mut v) = erased.violation { + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); + } + } + + // Accept modifications + if can_modify { + if let Some(mp) = erased.modified_payload { + *payload = mp; + } + if let Some(owned) = erased.modified_extensions { + // Validate tier constraints before accepting + let valid = extensions.validate_immutable(&owned); + if !valid { + warn!( + "{} plugin '{}' violated immutable tier โ€” \ + modified an immutable extension slot. \ + Extension changes rejected.", + phase_label, plugin_name + ); + } else if capabilities.contains("read_labels") { + // Only enforce monotonic labels if the plugin + // could see them. A plugin without read_labels + // has empty labels in its filtered view โ€” that's + // not a removal. + if let (Some(ref orig_sec), Some(ref new_sec)) = + (&extensions.security, &owned.security) + { + if !new_sec.labels.is_superset(&orig_sec.labels) { + warn!( + "{} plugin '{}' violated monotonic tier โ€” \ + removed a security label. \ + Extension changes rejected.", + phase_label, plugin_name + ); + } else { + extensions.merge_owned(owned); + } + } else { + extensions.merge_owned(owned); + } + } else { + extensions.merge_owned(owned); + } + } + } + + // Plugin writes to ctx.global_state are committed back + // to the canonical store via store_context() below. + } + // If extract failed or no modifications โ€” payload unchanged + } + Ok(Err(e)) => { + error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); + match on_error { + OnError::Fail if can_block => { + let mut v = crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); + } + // Any non-halt outcome (Fail-in-non-blocking-phase, + // Ignore, Disable): record the error so the caller + // sees it in PipelineResult.errors instead of + // having to read the warn-log. + OnError::Fail => { + warn!( + "{} plugin '{}' on_error=fail in non-blocking phase โ€” not halting", + phase_label, plugin_name, + ); + errors.push((&e).into()); + } + OnError::Ignore => { + errors.push((&e).into()); + } + OnError::Disable => { + warn!( + "{} plugin '{}' disabled after error", + phase_label, plugin_name + ); + errors.push((&e).into()); + entry.plugin_ref.disable(); + } + } + } + Err(_) => { + error!("{} plugin '{}' timed out", phase_label, plugin_name); + let timeout_err = crate::error::PluginError::Timeout { + plugin_name: plugin_name.to_string(), + timeout_ms: timeout_dur.as_millis() as u64, + proto_error_code: None, + }; + match on_error { + OnError::Fail if can_block => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); + } + OnError::Fail => { + warn!( + "{} plugin '{}' on_error=fail (timeout) in non-blocking phase โ€” not halting", + phase_label, plugin_name, + ); + errors.push((&timeout_err).into()); + } + OnError::Ignore => { + errors.push((&timeout_err).into()); + } + OnError::Disable => { + warn!( + "{} plugin '{}' disabled after timeout", + phase_label, plugin_name + ); + errors.push((&timeout_err).into()); + entry.plugin_ref.disable(); + } + } + } + } + + // Commit this plugin's context back to the table โ€” replaces the + // canonical global_state with its (possibly modified) copy and + // stores the local_state for the next hook invocation. The + // global_state move is free; only the local_state insert allocates. + ctx_table.store_context(plugin_id, ctx); + } + + None // no denial + } + + // ----------------------------------------------------------------------- + // Phase 3 & 5: Read-only execution (AUDIT / FIRE_AND_FORGET) + // ----------------------------------------------------------------------- + + /// Run a read-only phase โ€” plugins receive &payload, results discarded. + async fn run_ref_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx_table: &PluginContextTable, + phase_label: &str, + errors: &mut Vec, + ) { + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let plugin_id = entry.plugin_ref.id(); + let on_error = entry.plugin_ref.trusted_config().on_error; + // Read-only phase โ€” snapshot the plugin's local_state and the + // canonical global_state, no merge-back. + let mut ctx = ctx_table.snapshot_context(plugin_id); + // Filter extensions per plugin โ€” read-only, no write tokens. + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let filtered = filter_extensions(extensions, &capabilities); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let result = timeout( + timeout_dur, + entry.handler.invoke(payload, &filtered, &mut ctx), + ) + .await; + + // Audit / fire-and-forget cannot block, so OnError::Fail can't + // halt the pipeline โ€” but OnError::Disable must still take a + // repeatedly-failing plugin out of rotation. The previous code + // ignored on_error entirely, so Disable plugins kept failing + // forever no matter how many invocations errored. All non-halt + // failures also push a record into PipelineResult.errors. + match result { + Ok(Ok(_)) => {} // read-only โ€” discard result and ext_clone + Ok(Err(e)) => { + warn!( + "{} plugin '{}' error (ignored): {}", + phase_label, plugin_name, e + ); + errors.push((&e).into()); + if matches!(on_error, OnError::Disable) { + warn!( + "{} plugin '{}' disabled after error", + phase_label, plugin_name + ); + entry.plugin_ref.disable(); + } + } + Err(_) => { + warn!( + "{} plugin '{}' timed out (ignored)", + phase_label, plugin_name + ); + let timeout_err = crate::error::PluginError::Timeout { + plugin_name: plugin_name.clone(), + timeout_ms: timeout_dur.as_millis() as u64, + proto_error_code: None, + }; + errors.push((&timeout_err).into()); + if matches!(on_error, OnError::Disable) { + warn!( + "{} plugin '{}' disabled after timeout", + phase_label, plugin_name + ); + entry.plugin_ref.disable(); + } + } + } + } + } + + // ----------------------------------------------------------------------- + // Phase 4: Concurrent (parallel, fail-fast) + // ----------------------------------------------------------------------- + + /// Run the concurrent phase โ€” plugins execute truly in parallel. + /// Returns the first violation if any plugin denies. + /// + /// Uses a `JoinSet` rather than `Vec + join_all` so we can: + /// - react to results as they complete (`join_next_with_id`) rather than + /// waiting for the slowest task before noticing a deny; + /// - cancel remaining tasks when a halt condition is hit (`abort_all`), + /// making `short_circuit_on_deny` actually short-circuit and bounding + /// the side-effects timed-out / errored handlers can produce. + async fn run_concurrent_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx_table: &PluginContextTable, + errors: &mut Vec, + ) -> Option { + if entries.is_empty() { + return None; + } + + // Clone the payload once so each spawned task can borrow from + // an owned, 'static copy. Each task gets its own Arc'd clone. + let shared_payload: Arc> = Arc::new(payload.clone_boxed()); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + // Spawn into a JoinSet keyed by tokio task::Id so we can map a + // completed task (or a panicked one โ€” JoinError carries the id) + // back to its entry without positional zip. + type ConcurrentTaskOutput = Result< + Result, Box>, + tokio::time::error::Elapsed, + >; + let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); + let mut id_to_index: std::collections::HashMap = + std::collections::HashMap::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + let handler = Arc::clone(&entry.handler); + let payload_clone = Arc::clone(&shared_payload); + let plugin_id = entry.plugin_ref.id(); + // Snapshot the plugin's local_state and the canonical global_state. + // Concurrent plugins do not merge back โ€” each task owns its copy. + let mut ctx = ctx_table.snapshot_context(plugin_id); + let dur = timeout_dur; + + // 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 abort_handle = set.spawn(async move { + timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await + }); + id_to_index.insert(abort_handle.id(), idx); + } + + let mut denials: Vec = Vec::new(); + + while let Some(joined) = set.join_next_with_id().await { + // Pull the task::Id and outcome out of the success/error envelope + // so we can look up the entry by id even when the task panicked. + let (task_id, outcome) = match joined { + Ok((id, result)) => (id, Ok(result)), + Err(join_err) => { + let id = join_err.id(); + (id, Err(join_err)) + } + }; + let idx = match id_to_index.get(&task_id) { + Some(i) => *i, + None => { + // Should be impossible โ€” we registered every spawn. + error!("CONCURRENT: untracked task id {:?}", task_id); + continue; + } + }; + let entry = &entries[idx]; + let plugin_name = entry.plugin_ref.name(); + let on_error = entry.plugin_ref.trusted_config().on_error; + + let result = match outcome { + Ok(r) => r, + Err(e) => { + // Spawned task panicked. Apply the plugin's on_error + // policy just like a returned error or timeout. On + // Fail, abort the remaining tasks before halting. + error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, e); + let panic_err = crate::error::PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("task panicked: {}", e), + source: None, + code: Some("panic".into()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_panic", + format!("Plugin '{}' task panicked: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); + errors.push((&panic_err).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); + errors.push((&panic_err).into()); + entry.plugin_ref.disable(); + } + } + continue; + } + }; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + if !erased.continue_processing { + let mut violation = erased.violation.unwrap_or_else(|| { + crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ) + }); + violation.plugin_name = Some(plugin_name.to_string()); + if self.config.short_circuit_on_deny { + // Real short-circuit: cancel the rest before + // they keep running and writing side-effects. + set.abort_all(); + return Some(violation); + } + denials.push(violation); + } + } + } + Ok(Err(e)) => match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); + errors.push((&e).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); + errors.push((&e).into()); + entry.plugin_ref.disable(); + } + }, + Err(_) => { + let timeout_err = crate::error::PluginError::Timeout { + plugin_name: plugin_name.to_string(), + timeout_ms: timeout_dur.as_millis() as u64, + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); + errors.push((&timeout_err).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); + errors.push((&timeout_err).into()); + entry.plugin_ref.disable(); + } + } + } + } + } + + // Return first denial if any were collected (non-short-circuit mode). + // Dropping `set` here also aborts any not-yet-completed tasks; with + // join_next_with_id() above we drained completions, so this is just + // belt-and-braces in case the loop exited unexpectedly. + denials.into_iter().next() + } + + // ----------------------------------------------------------------------- + // 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. + /// + /// Returns the plugin name and join handle for each spawned task + /// so they can be stored on `PipelineResult` for optional awaiting + /// via `wait_for_background_tasks()`. + fn spawn_fire_and_forget( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx_table: &PluginContextTable, + task_tracker: &tokio_util::task::TaskTracker, + ) -> Vec<(String, tokio::task::JoinHandle<()>)> { + if entries.is_empty() { + return Vec::new(); + } + + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let mut handles = Vec::with_capacity(entries.len()); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let handler = Arc::clone(&entry.handler); + let owned_payload = payload.clone_boxed(); + // Snapshot per plugin so fire-and-forget tasks see their stored + // local_state from prior hooks, not just an empty context. + let mut ctx = ctx_table.snapshot_context(entry.plugin_ref.id()); + let dur = timeout_dur; + let name_for_log = plugin_name.clone(); + + // Filter per plugin, read-only, no write tokens + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let filtered = Arc::new(filter_extensions(extensions, &capabilities)); + + // Spawn through TaskTracker so `PluginManager::shutdown()` + // can drain in-flight fire-and-forget tasks before tearing + // down. The returned JoinHandle is the same shape as + // tokio::spawn's, so callers using BackgroundTasks still + // wait_for_background_tasks() over their own handles. + let handle = task_tracker.spawn(async move { + let result = + timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await; + + match result { + Ok(Ok(_)) => {} // discard + Ok(Err(e)) => { + warn!( + "FIRE_AND_FORGET plugin '{}' error (ignored): {}", + name_for_log, e + ); + } + Err(_) => { + warn!( + "FIRE_AND_FORGET plugin '{}' timed out (ignored)", + name_for_log + ); + } + } + }); + + handles.push((plugin_name, handle)); + } + + handles + } +} + +impl Default for Executor { + fn default() -> Self { + Self::new(ExecutorConfig::default()) + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +// SerialResult removed โ€” run_serial_phase now returns Option directly. + +// --------------------------------------------------------------------------- +// Erased Result Extraction +// --------------------------------------------------------------------------- + +/// Common fields extracted from a type-erased PluginResult. +/// +/// Handlers return `Box` which wraps this struct. The +/// executor extracts it via [`extract_erased()`] to read the +/// control flow fields without knowing the concrete payload type. +pub struct ErasedResultFields { + pub continue_processing: bool, + pub modified_payload: Option>, + pub modified_extensions: Option, + pub violation: Option, +} + +/// Extract erased result fields from a type-erased handler result. +/// +/// Takes ownership of the Box โ€” the executor consumes the result. +/// Logs a warning if the downcast fails (indicates a handler returned +/// the wrong type โ€” a framework bug, not a plugin error). +pub fn extract_erased(result: Box) -> Option { + match result.downcast::() { + Ok(b) => Some(*b), + Err(_) => { + warn!("extract_erased: downcast failed โ€” handler returned unexpected type"); + None + } + } +} + +/// Convert a typed `PluginResult

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

, +) -> Box { + Box::new(ErasedResultFields { + continue_processing: result.continue_processing, + modified_payload: result + .modified_payload + .map(|p| Box::new(p) as Box), + modified_extensions: result.modified_extensions, + violation: result.violation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::payload::PluginPayload; + use crate::hooks::PluginResult; + + #[derive(Debug, Clone)] + #[allow(dead_code)] // test fixture โ€” typed shape is the point, not field reads + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + #[test] + fn test_erase_result_allow() { + let result: PluginResult = PluginResult::allow(); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.violation.is_none()); + assert!(fields.modified_payload.is_none()); + } + + #[test] + fn test_erase_result_deny() { + let result: PluginResult = + PluginResult::deny(crate::error::PluginViolation::new("test", "denied")); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(!fields.continue_processing); + assert_eq!(fields.violation.as_ref().unwrap().code, "test"); + } + + #[test] + fn test_erase_result_modify_payload() { + let result: PluginResult = PluginResult::modify_payload(TestPayload { + value: "modified".into(), + }); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_payload.is_some()); + } + + #[test] + fn test_erase_result_modify_extensions() { + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("PII"); + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + let owned = ext.cow_copy(); + let result: PluginResult = PluginResult::modify_extensions(owned); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_extensions.is_some()); + let sec = fields + .modified_extensions + .as_ref() + .unwrap() + .security + .as_ref() + .unwrap(); + assert!(sec.has_label("PII")); + } + + #[test] + fn test_pipeline_result_allowed() { + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let result = + PipelineResult::allowed_with(payload, Extensions::default(), PluginContextTable::new()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); + assert!(result.violation.is_none()); + } + + #[test] + fn test_pipeline_result_denied() { + let violation = crate::error::PluginViolation::new("test", "denied"); + let result = + PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new()); + assert!(!result.continue_processing); + assert!(result.modified_payload.is_none()); + assert!(result.violation.is_some()); + } + + #[tokio::test] + async fn test_executor_empty_entries() { + let executor = Executor::default(); + let tracker = tokio_util::task::TaskTracker::new(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = executor + .execute(&[], payload, Extensions::default(), None, &tracker) + .await; + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); + } +} diff --git a/crates/cpex-core/src/extensions/agent.rs b/crates/cpex-core/src/extensions/agent.rs new file mode 100644 index 00000000..f6eb9c3b --- /dev/null +++ b/crates/cpex-core/src/extensions/agent.rs @@ -0,0 +1,60 @@ +// Location: ./crates/cpex-core/src/extensions/agent.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AgentExtension โ€” session, conversation, agent lineage. +// Mirrors cpex/framework/extensions/agent.py. + +use serde::{Deserialize, Serialize}; + +/// Conversation history context. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConversationContext { + /// Recent conversation history (lightweight summaries). + #[serde(default)] + pub history: Vec, + + /// LLM-generated summary of the conversation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Detected topics in the conversation. + #[serde(default)] + pub topics: Vec, +} + +/// Agent execution context extension. +/// +/// Carries session tracking, conversation context, multi-agent +/// lineage, and the original user/agent input. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentExtension { + /// Original user/agent input that triggered this action. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + + /// Broad user/agent session identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + + /// Specific dialogue/task identifier within a session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + + /// Position within the conversation (0-indexed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn: Option, + + /// Identifier of the agent that produced this message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + + /// If spawned by another agent, the parent's ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_agent_id: Option, + + /// Optional conversation context with history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation: Option, +} diff --git a/crates/cpex-core/src/extensions/completion.rs b/crates/cpex-core/src/extensions/completion.rs new file mode 100644 index 00000000..2c3ad14d --- /dev/null +++ b/crates/cpex-core/src/extensions/completion.rs @@ -0,0 +1,71 @@ +// Location: ./crates/cpex-core/src/extensions/completion.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CompletionExtension โ€” LLM completion information. +// Mirrors cpex/framework/extensions/completion.py. + +use serde::{Deserialize, Serialize}; + +/// Why the model stopped generating. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + /// Natural end of message. + End, + /// Complete response (Harmony format). + Return, + /// Tool/function invocation. + Call, + /// Hit token limit. + MaxTokens, + /// Hit custom stop sequence. + StopSequence, +} + +/// Token usage statistics. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenUsage { + /// Input tokens consumed. + #[serde(default)] + pub input_tokens: u32, + + /// Output tokens generated. + #[serde(default)] + pub output_tokens: u32, + + /// Total tokens (input + output). + #[serde(default)] + pub total_tokens: u32, +} + +/// LLM completion information. +/// +/// Immutable โ€” set after the LLM responds. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CompletionExtension { + /// Why the model stopped generating. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + + /// Token usage statistics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + /// Model identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Raw response format (chatml, harmony, gemini, anthropic). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_format: Option, + + /// Creation timestamp (ISO 8601). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + + /// Response latency in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, +} diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs new file mode 100644 index 00000000..6409bf43 --- /dev/null +++ b/crates/cpex-core/src/extensions/container.rs @@ -0,0 +1,711 @@ +// 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. + /// + /// A slot that is `None` in modified (because capability filtering + /// hid it from the plugin) is always valid โ€” the plugin never saw + /// it. Only flag as tampering when both are `Some` with different + /// Arc pointers, or when the original is `None` but modified is + /// `Some` (the plugin fabricated a slot it shouldn't have). + pub fn validate_immutable(&self, modified: &OwnedExtensions) -> bool { + fn ptr_eq_opt(a: &Option>, b: &Option>) -> bool { + match (a, b) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + (_, None) => true, // plugin never saw it โ€” not tampering + (None, Some(_)) => false, // plugin fabricated a slot + } + } + + ptr_eq_opt(&self.request, &modified.request) + && ptr_eq_opt(&self.agent, &modified.agent) + && ptr_eq_opt(&self.mcp, &modified.mcp) + && ptr_eq_opt(&self.completion, &modified.completion) + && ptr_eq_opt(&self.provenance, &modified.provenance) + && ptr_eq_opt(&self.llm, &modified.llm) + && ptr_eq_opt(&self.framework, &modified.framework) + && ptr_eq_opt(&self.meta, &modified.meta) + } + + /// 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::delegation::DelegationHop; + use crate::extensions::DelegationExtension; + + // 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_validate_immutable_passes_when_slot_filtered_out() { + // Bug fix regression: when capability filtering hides a slot + // from the plugin (e.g., agent=None in owned because plugin + // lacks read_agent), validate_immutable must NOT treat that + // as tampering. + let ext = make_extensions(); + let mut cow = ext.cow_copy(); + + // Simulate capability filtering hiding the agent slot + cow.agent = None; + + // Validation should pass โ€” plugin never saw the slot + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_validate_immutable_fails_when_slot_fabricated() { + // If the original has no agent but the plugin returns one, + // that's fabrication โ€” should fail. + let ext = Extensions::default(); // no agent + let mut cow = ext.cow_copy(); + + cow.agent = Some(Arc::new(AgentExtension { + agent_id: Some("fabricated".into()), + ..Default::default() + })); + + assert!(!ext.validate_immutable(&cow)); + } + + #[test] + fn test_validate_immutable_passes_multiple_slots_filtered() { + // Multiple immutable slots filtered out โ€” all should pass + let ext = make_extensions(); + let mut cow = ext.cow_copy(); + + cow.agent = None; + cow.mcp = None; + cow.completion = None; + cow.framework = None; + + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_merge_owned_preserves_http_response_headers() { + // Bug fix regression: merge_owned must preserve response + // headers written by a plugin through Guarded write access. + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer tok"); + + let mut ext = Extensions { + http: Some(Arc::new(http)), + ..Default::default() + }; + ext.http_write_token = Some(WriteToken::new()); + + let mut cow = ext.cow_copy(); + + // Plugin writes response headers through the guard + let token = cow.http_write_token.as_ref().unwrap(); + let h = cow.http.as_mut().unwrap().write(token); + h.set_response_header("X-Tool-Name", "get_compensation"); + h.set_response_header("X-Status", "success"); + + // Merge back + ext.merge_owned(cow); + + // Response headers must be present after merge + let merged_http = ext.http.as_ref().unwrap(); + assert_eq!( + merged_http.get_response_header("X-Tool-Name"), + Some("get_compensation") + ); + assert_eq!(merged_http.get_response_header("X-Status"), Some("success")); + // Original request headers preserved + assert_eq!( + merged_http.get_request_header("Authorization"), + Some("Bearer tok") + ); + } + + #[test] + fn test_merge_owned_with_filtered_security() { + // A plugin without read_labels gets empty labels in its + // filtered view. After cow_copy + merge_owned, the pipeline's + // security labels must be preserved (not overwritten with empty). + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR"); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + // Simulate: plugin has no read_labels, so filtered security + // has empty labels. cow_copy of filtered would have empty labels. + let mut cow = ext.cow_copy(); + + // Plugin's owned security has the labels (from cow_copy of full ext) + // But in the real flow, it would be from the filtered ext. + // Simulate filtered: clear labels + cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::new(); + + // merge_owned replaces pipeline security with owned + let mut ext_mut = ext.clone(); + ext_mut.merge_owned(cow); + + // After merge, the security comes from the owned (which had empty labels) + // This is expected โ€” the executor's monotonic check should prevent + // this case. merge_owned itself is just a field replacement. + let merged_sec = ext_mut.security.as_ref().unwrap(); + assert!(!merged_sec.has_label("PII")); // replaced by owned + } + + #[test] + fn test_merge_owned_none_http_preserves_pipeline() { + // If owned.http is None (plugin had no read_headers capability), + // merge_owned replaces with None. The executor should only call + // merge_owned when the plugin actually modified something. + let mut http = HttpExtension::default(); + http.set_request_header("X-Original", "value"); + + let mut ext = Extensions { + http: Some(Arc::new(http)), + ..Default::default() + }; + + let mut cow = ext.cow_copy(); + cow.http = None; // simulate filtered-out HTTP + + ext.merge_owned(cow); + + // HTTP is now None โ€” this is the raw merge behavior. + // The executor guards against this by only calling merge_owned + // when the plugin returned modify_extensions. + assert!(ext.http.is_none()); + } + + #[test] + fn test_read_only_plugin_zero_cost() { + // Plugin that only reads โ€” no cow_copy, no clone + let ext = make_extensions(); + + // Read security labels + let has_pii = ext + .security + .as_ref() + .map(|s| s.has_label("PII")) + .unwrap_or(false); + assert!(has_pii); + + // Read HTTP headers + let auth = ext + .http + .as_ref() + .and_then(|h| h.get_header("Authorization")); + assert_eq!(auth, Some("Bearer token")); + + // Read meta + let entity = ext.meta.as_ref().and_then(|m| m.entity_type.as_deref()); + assert_eq!(entity, Some("tool")); + + // No cow_copy called โ€” zero allocations for read-only access + } +} diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs new file mode 100644 index 00000000..e5f5ef50 --- /dev/null +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -0,0 +1,165 @@ +// 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 { + origin_subject_id: Some("alice".into()), + ..Default::default() + }; + + del.append_hop(DelegationHop { + subject_id: "alice".into(), + audience: Some("service-b".into()), + scopes_granted: vec!["read".into(), "write".into()], + strategy: Some("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 { + origin_subject_id: Some("alice".into()), + actor_subject_id: Some("service-b".into()), + ..Default::default() + }; + del.append_hop(DelegationHop { + subject_id: "alice".into(), + subject_type: Some("user".into()), + 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..1841164a --- /dev/null +++ b/crates/cpex-core/src/extensions/filter.rs @@ -0,0 +1,547 @@ +// Location: ./crates/cpex-core/src/extensions/filter.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Extension filtering โ€” capability-gated visibility. +// +// Builds a Extensions from Extensions + declared capabilities. +// Secure by default: slots not explicitly included are None. +// +// Mirrors cpex/framework/extensions/tiers.py::filter_extensions(). + +use std::collections::HashSet; +use std::sync::Arc; + +use super::container::Extensions; + +use super::security::{SecurityExtension, SubjectExtension}; +use super::tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; + +// --------------------------------------------------------------------------- +// Slot Registry โ€” static policies per extension slot +// --------------------------------------------------------------------------- + +/// Extension slot identifiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SlotName { + Request, + Agent, + Http, + Meta, + Delegation, + Custom, + Mcp, + Completion, + Provenance, + Llm, + Framework, + // Security sub-slots + SecurityLabels, + SecuritySubject, + SecuritySubjectRoles, + SecuritySubjectTeams, + SecuritySubjectClaims, + SecuritySubjectPermissions, + SecurityObjects, + SecurityData, +} + +/// Get the policy for a given slot. +pub fn slot_policy(slot: SlotName) -> SlotPolicy { + match slot { + // Unrestricted immutable โ€” always visible + SlotName::Request => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Provenance => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Completion => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Llm => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Framework => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Mcp => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Meta => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Custom => SlotPolicy { + tier: MutabilityTier::Mutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + // Capability-gated + SlotName::Agent => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadAgent), + write_cap: None, + }, + SlotName::Http => SlotPolicy { + tier: MutabilityTier::Mutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadHeaders), + write_cap: Some(Capability::WriteHeaders), + }, + SlotName::Delegation => SlotPolicy { + tier: MutabilityTier::Monotonic, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadDelegation), + write_cap: Some(Capability::AppendDelegation), + }, + // Security sub-slots + SlotName::SecurityLabels => SlotPolicy { + tier: MutabilityTier::Monotonic, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadLabels), + write_cap: Some(Capability::AppendLabels), + }, + SlotName::SecuritySubject => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadSubject), + write_cap: None, + }, + SlotName::SecuritySubjectRoles => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadRoles), + write_cap: None, + }, + SlotName::SecuritySubjectTeams => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadTeams), + write_cap: None, + }, + SlotName::SecuritySubjectClaims => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadClaims), + write_cap: None, + }, + SlotName::SecuritySubjectPermissions => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadPermissions), + write_cap: None, + }, + SlotName::SecurityObjects => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::SecurityData => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + } +} + +// --------------------------------------------------------------------------- +// Capability Checking +// --------------------------------------------------------------------------- + +/// Check if a set of capabilities grants read access to a slot. +fn has_read_access(policy: &SlotPolicy, capabilities: &HashSet) -> bool { + if policy.access == AccessPolicy::Unrestricted { + return true; + } + if let Some(read_cap) = &policy.read_cap { + let cap_str = serde_json::to_string(read_cap) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + if capabilities.contains(&cap_str) { + return true; + } + } + // Check if any subject sub-field cap implies read_subject + if policy.read_cap == Some(Capability::ReadSubject) { + return has_any_subject_capability(capabilities); + } + false +} + +/// Check if capabilities include any subject-related capability. +fn has_any_subject_capability(capabilities: &HashSet) -> bool { + let subject_caps = [ + Capability::ReadSubject, + Capability::ReadRoles, + Capability::ReadTeams, + Capability::ReadClaims, + Capability::ReadPermissions, + ]; + for cap in &subject_caps { + let cap_str = serde_json::to_string(cap) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + if capabilities.contains(&cap_str) { + return true; + } + } + false +} + +/// Helper: convert Capability to its string representation. +fn cap_str(cap: Capability) -> String { + serde_json::to_string(&cap) + .unwrap_or_default() + .trim_matches('"') + .to_string() +} + +// --------------------------------------------------------------------------- +// Filter Extensions +// --------------------------------------------------------------------------- + +/// Build a Extensions containing only slots the plugin can access. +/// +/// Starts from an empty Extensions and clones in only the +/// slots the plugin has read access to. Slots not explicitly included +/// are `None`. Secure by default โ€” if a new slot is added to +/// Extensions but not registered here, it remains hidden. +/// +/// For the security extension, filtering is granular: unrestricted +/// sub-fields (objects, data, classification) are always included, +/// while labels and subject sub-fields are gated by capabilities. +pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet) -> Extensions { + // Build the unrestricted-immutable fields up front; capability-gated + // slots stay default and are filled in below. + let mut filtered = Extensions { + request: extensions.request.clone(), + provenance: extensions.provenance.clone(), + completion: extensions.completion.clone(), + llm: extensions.llm.clone(), + framework: extensions.framework.clone(), + mcp: extensions.mcp.clone(), + meta: extensions.meta.clone(), + custom: extensions.custom.clone(), + ..Default::default() + }; + + // Capability-gated: delegation + if extensions.delegation.is_some() { + let policy = slot_policy(SlotName::Delegation); + if has_read_access(&policy, capabilities) { + filtered.delegation = extensions.delegation.clone(); + } + } + + // Capability-gated: agent + if extensions.agent.is_some() { + let policy = slot_policy(SlotName::Agent); + if has_read_access(&policy, capabilities) { + filtered.agent = extensions.agent.clone(); + } + } + + // Capability-gated: http + if extensions.http.is_some() { + let policy = slot_policy(SlotName::Http); + if has_read_access(&policy, capabilities) { + filtered.http = extensions.http.clone(); + } + } + + // Security โ€” granular sub-field filtering + if let Some(ref security) = extensions.security { + filtered.security = Some(Arc::new(build_filtered_security(security, capabilities))); + } + + filtered +} + +/// Build a filtered SecurityExtension containing only accessible fields. +/// +/// Unrestricted sub-fields (objects, data, classification) are always +/// included. Labels and subject sub-fields are gated by capabilities. +fn build_filtered_security( + security: &SecurityExtension, + capabilities: &HashSet, +) -> SecurityExtension { + let mut filtered = SecurityExtension { + // Unrestricted โ€” always included + objects: security.objects.clone(), + data: security.data.clone(), + classification: security.classification.clone(), + // Agent identity and auth method โ€” always included (host-set, immutable) + agent: security.agent.clone(), + auth_method: security.auth_method.clone(), + // Default empty for capability-gated fields + labels: super::MonotonicSet::new(), + subject: None, + }; + + // Labels โ€” capability-gated + let labels_policy = slot_policy(SlotName::SecurityLabels); + if has_read_access(&labels_policy, capabilities) { + filtered.labels = security.labels.clone(); + } + + // Subject โ€” granular capability-gated + if let Some(ref subject) = security.subject { + if has_any_subject_capability(capabilities) { + filtered.subject = Some(build_filtered_subject(subject, capabilities)); + } + } + + filtered +} + +/// Build a filtered SubjectExtension containing only accessible fields. +/// +/// Always includes id and type (base subject access). Individual +/// sub-fields are only populated if the plugin holds the capability. +fn build_filtered_subject( + subject: &SubjectExtension, + capabilities: &HashSet, +) -> SubjectExtension { + SubjectExtension { + // Always included with any subject access + id: subject.id.clone(), + subject_type: subject.subject_type, + // Capability-gated sub-fields + roles: if capabilities.contains(&cap_str(Capability::ReadRoles)) { + subject.roles.clone() + } else { + HashSet::new() + }, + permissions: if capabilities.contains(&cap_str(Capability::ReadPermissions)) { + subject.permissions.clone() + } else { + HashSet::new() + }, + teams: if capabilities.contains(&cap_str(Capability::ReadTeams)) { + subject.teams.clone() + } else { + HashSet::new() + }, + claims: if capabilities.contains(&cap_str(Capability::ReadClaims)) { + subject.claims.clone() + } else { + std::collections::HashMap::new() + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::meta::MetaExtension; + use crate::extensions::SecurityExtension; + + fn make_full_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.classification = Some("confidential".into()); + security.subject = Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(super::super::security::SubjectType::User), + roles: ["admin".to_string()].into(), + permissions: ["read_all".to_string()].into(), + teams: ["engineering".to_string()].into(), + claims: [("iss".to_string(), "example.com".to_string())].into(), + }); + + let mut http = super::super::HttpExtension::default(); + http.set_header("Authorization", "Bearer token123"); + + Extensions { + request: Some(std::sync::Arc::new(super::super::RequestExtension { + request_id: Some("req-001".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(std::sync::Arc::new(http)), + agent: Some(std::sync::Arc::new(super::super::AgentExtension { + agent_id: Some("agent-1".into()), + ..Default::default() + })), + delegation: Some(std::sync::Arc::new(super::super::DelegationExtension { + delegated: true, + ..Default::default() + })), + meta: Some(std::sync::Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + custom: Some(Arc::new( + [("key".to_string(), serde_json::json!("value"))].into(), + )), + ..Default::default() + } + } + + #[test] + fn test_no_capabilities_sees_unrestricted_only() { + let ext = make_full_extensions(); + let caps = HashSet::new(); + let filtered = filter_extensions(&ext, &caps); + + // Unrestricted slots visible + assert!(filtered.request.is_some()); + assert!(filtered.meta.is_some()); + assert!(filtered.custom.is_some()); + + // Capability-gated slots hidden + assert!(filtered.http.is_none()); + assert!(filtered.agent.is_none()); + assert!(filtered.delegation.is_none()); + + // Security: objects/data/classification visible, labels/subject hidden + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.labels.is_empty()); + assert!(sec.subject.is_none()); + assert_eq!(sec.classification, Some("confidential".into())); + } + + #[test] + fn test_read_headers_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_headers".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.http.is_some()); + assert_eq!( + filtered.http.unwrap().get_header("Authorization"), + Some("Bearer token123") + ); + // Still no agent access + assert!(filtered.agent.is_none()); + } + + #[test] + fn test_read_agent_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_agent".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.agent.is_some()); + assert_eq!(filtered.agent.unwrap().agent_id, Some("agent-1".into())); + assert!(filtered.http.is_none()); + } + + #[test] + fn test_read_labels_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_labels".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.has_label("PII")); + // No subject access โ€” just label access + assert!(sec.subject.is_none()); + } + + #[test] + fn test_read_subject_sees_id_and_type_only() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_subject".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + let sec = filtered.security.as_ref().unwrap(); + let subject = sec.subject.as_ref().unwrap(); + assert_eq!(subject.id, Some("alice".into())); + // Sub-fields empty without specific capabilities + assert!(subject.roles.is_empty()); + assert!(subject.permissions.is_empty()); + assert!(subject.teams.is_empty()); + assert!(subject.claims.is_empty()); + } + + #[test] + fn test_read_roles_implies_subject_access() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_roles".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + let sec = filtered.security.as_ref().unwrap(); + let subject = sec.subject.as_ref().unwrap(); + // Has subject access (implied by read_roles) + assert_eq!(subject.id, Some("alice".into())); + // Has roles + assert!(subject.roles.contains("admin")); + // No other sub-fields + assert!(subject.permissions.is_empty()); + assert!(subject.teams.is_empty()); + } + + #[test] + fn test_full_capabilities() { + let ext = make_full_extensions(); + let caps: HashSet = [ + "read_headers", + "read_agent", + "read_delegation", + "read_labels", + "read_subject", + "read_roles", + "read_permissions", + "read_teams", + "read_claims", + ] + .into_iter() + .map(String::from) + .collect(); + + let filtered = filter_extensions(&ext, &caps); + + // Everything visible + assert!(filtered.http.is_some()); + assert!(filtered.agent.is_some()); + assert!(filtered.delegation.is_some()); + + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.has_label("PII")); + let subject = sec.subject.as_ref().unwrap(); + assert!(subject.roles.contains("admin")); + assert!(subject.permissions.contains("read_all")); + assert!(subject.teams.contains("engineering")); + assert!(subject.claims.contains_key("iss")); + } + + #[test] + fn test_read_delegation_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_delegation".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.delegation.is_some()); + assert!(filtered.delegation.unwrap().delegated); + } +} 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..fb369a16 --- /dev/null +++ b/crates/cpex-core/src/extensions/guarded.rs @@ -0,0 +1,144 @@ +// 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..3fa1157a --- /dev/null +++ b/crates/cpex-core/src/extensions/http.rs @@ -0,0 +1,210 @@ +// Location: ./crates/cpex-core/src/extensions/http.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HttpExtension โ€” HTTP request and response headers. +// Mirrors cpex/framework/extensions/http.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// HTTP-related extensions. +/// +/// Carries both request and response headers separately. The host +/// populates what's available at each hook point: +/// - Pre-invoke: `request_headers` filled, `response_headers` empty +/// - Post-invoke: both filled (request from original, response from upstream) +/// +/// Capability-gated: requires `read_headers` to see, `write_headers` +/// to modify (both request and response). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HttpExtension { + /// HTTP request headers (inbound from caller). + #[serde(default)] + pub request_headers: HashMap, + + /// HTTP response headers (from upstream, populated post-invoke). + #[serde(default)] + pub response_headers: HashMap, +} + +impl HttpExtension { + // -- Request header helpers -- + + /// Set a request header (overwrites if exists). + pub fn set_request_header(&mut self, name: impl Into, value: impl Into) { + self.request_headers.insert(name.into(), value.into()); + } + + /// Get a request header value (case-insensitive lookup). + pub fn get_request_header(&self, name: &str) -> Option<&str> { + get_header_ci(&self.request_headers, name) + } + + /// Check if a request header exists (case-insensitive). + pub fn has_request_header(&self, name: &str) -> bool { + self.get_request_header(name).is_some() + } + + /// Add request header only if it doesn't exist. Returns true if added. + pub fn add_request_header( + &mut self, + name: impl Into, + value: impl Into, + ) -> bool { + let name = name.into(); + if self.has_request_header(&name) { + return false; + } + self.request_headers.insert(name, value.into()); + true + } + + /// Remove a request header by name. Returns the removed value. + pub fn remove_request_header(&mut self, name: &str) -> Option { + remove_header_ci(&mut self.request_headers, name) + } + + // -- Response header helpers -- + + /// Set a response header (overwrites if exists). + pub fn set_response_header(&mut self, name: impl Into, value: impl Into) { + self.response_headers.insert(name.into(), value.into()); + } + + /// Get a response header value (case-insensitive lookup). + pub fn get_response_header(&self, name: &str) -> Option<&str> { + get_header_ci(&self.response_headers, name) + } + + /// Check if a response header exists (case-insensitive). + pub fn has_response_header(&self, name: &str) -> bool { + self.get_response_header(name).is_some() + } + + // -- Convenience aliases (backward-compatible, default to request) -- + + /// Set a header on request headers (convenience alias). + pub fn set_header(&mut self, name: impl Into, value: impl Into) { + self.set_request_header(name, value); + } + + /// Get a header from request headers (convenience alias, case-insensitive). + pub fn get_header(&self, name: &str) -> Option<&str> { + self.get_request_header(name) + } + + /// Check if a request header exists (convenience alias). + pub fn has_header(&self, name: &str) -> bool { + self.has_request_header(name) + } +} + +// -- Internal helpers -- + +fn get_header_ci<'a>(headers: &'a HashMap, name: &str) -> Option<&'a str> { + let lower = name.to_lowercase(); + headers + .iter() + .find(|(k, _)| k.to_lowercase() == lower) + .map(|(_, v)| v.as_str()) +} + +fn remove_header_ci(headers: &mut HashMap, name: &str) -> Option { + let lower = name.to_lowercase(); + let key = headers.keys().find(|k| k.to_lowercase() == lower).cloned(); + key.and_then(|k| headers.remove(&k)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_header_set_and_get() { + let mut http = HttpExtension::default(); + http.set_request_header("Content-Type", "application/json"); + assert_eq!( + http.get_request_header("Content-Type"), + Some("application/json") + ); + } + + #[test] + fn test_request_header_case_insensitive() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer tok"); + assert_eq!(http.get_request_header("authorization"), Some("Bearer tok")); + assert_eq!(http.get_request_header("AUTHORIZATION"), Some("Bearer tok")); + } + + #[test] + fn test_response_header_set_and_get() { + let mut http = HttpExtension::default(); + http.set_response_header("Content-Type", "text/html"); + assert_eq!(http.get_response_header("Content-Type"), Some("text/html")); + assert!(http.has_response_header("content-type")); + } + + #[test] + fn test_request_and_response_independent() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer req-tok"); + http.set_response_header("X-Response-Time", "42ms"); + + // Request headers don't leak into response + assert!(http.get_response_header("Authorization").is_none()); + // Response headers don't leak into request + assert!(http.get_request_header("X-Response-Time").is_none()); + } + + #[test] + fn test_convenience_aliases_default_to_request() { + let mut http = HttpExtension::default(); + http.set_header("X-Custom", "value"); + assert_eq!(http.get_header("X-Custom"), Some("value")); + assert!(http.has_header("X-Custom")); + // Verify it went to request_headers + assert_eq!(http.get_request_header("X-Custom"), Some("value")); + } + + #[test] + fn test_add_request_header_only_if_absent() { + let mut http = HttpExtension::default(); + assert!(http.add_request_header("X-New", "first")); + assert!(!http.add_request_header("X-New", "second")); + assert_eq!(http.get_request_header("X-New"), Some("first")); + } + + #[test] + fn test_remove_request_header() { + let mut http = HttpExtension::default(); + http.set_request_header("X-Remove", "value"); + let removed = http.remove_request_header("x-remove"); + assert_eq!(removed, Some("value".to_string())); + assert!(!http.has_request_header("X-Remove")); + } + + #[test] + fn test_serde_roundtrip() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer tok"); + http.set_request_header("X-Request-ID", "req-123"); + http.set_response_header("Content-Type", "application/json"); + http.set_response_header("X-Response-Time", "15ms"); + + let json = serde_json::to_string(&http).unwrap(); + let deserialized: HttpExtension = serde_json::from_str(&json).unwrap(); + + assert_eq!( + deserialized.get_request_header("Authorization"), + Some("Bearer tok") + ); + assert_eq!( + deserialized.get_response_header("Content-Type"), + Some("application/json") + ); + } +} diff --git a/crates/cpex-core/src/extensions/llm.rs b/crates/cpex-core/src/extensions/llm.rs new file mode 100644 index 00000000..adc2225c --- /dev/null +++ b/crates/cpex-core/src/extensions/llm.rs @@ -0,0 +1,27 @@ +// Location: ./crates/cpex-core/src/extensions/llm.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// LLMExtension โ€” model identity and capabilities. +// Mirrors cpex/framework/extensions/llm.py. + +use serde::{Deserialize, Serialize}; + +/// Model identity and capabilities. +/// +/// Immutable โ€” set by the host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LLMExtension { + /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4-20250514"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + + /// Provider name (e.g., "openai", "anthropic"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + + /// Model capabilities (e.g., "tool_use", "vision", "streaming"). + #[serde(default)] + pub capabilities: Vec, +} diff --git a/crates/cpex-core/src/extensions/mcp.rs b/crates/cpex-core/src/extensions/mcp.rs new file mode 100644 index 00000000..c5eed384 --- /dev/null +++ b/crates/cpex-core/src/extensions/mcp.rs @@ -0,0 +1,115 @@ +// Location: ./crates/cpex-core/src/extensions/mcp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MCPExtension โ€” tool, resource, or prompt metadata. +// Mirrors cpex/framework/extensions/mcp.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// MCP tool metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ToolMetadata { + /// Tool name. + pub name: String, + + /// Human-readable title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Tool description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Input JSON schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + + /// Output JSON schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + + /// Source server ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + + /// Tool namespace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + /// Tool annotations. + #[serde(default)] + pub annotations: HashMap, +} + +/// MCP resource metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ResourceMetadata { + /// Resource URI. + pub uri: String, + + /// Human-readable name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Resource description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// MIME type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + + /// Source server ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + + /// Resource annotations. + #[serde(default)] + pub annotations: HashMap, +} + +/// MCP prompt metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PromptMetadata { + /// Prompt name. + pub name: String, + + /// Prompt description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Prompt arguments schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + + /// Source server ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + + /// Prompt annotations. + #[serde(default)] + pub annotations: HashMap, +} + +/// MCP-specific metadata extension. +/// +/// Carries tool, resource, or prompt metadata for the entity +/// being processed. Immutable โ€” set by the host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MCPExtension { + /// Tool metadata (if this message involves a tool). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, + + /// Resource metadata (if this message involves a resource). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + + /// Prompt metadata (if this message involves a prompt). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, +} diff --git a/crates/cpex-core/src/extensions/meta.rs b/crates/cpex-core/src/extensions/meta.rs new file mode 100644 index 00000000..4ba55516 --- /dev/null +++ b/crates/cpex-core/src/extensions/meta.rs @@ -0,0 +1,45 @@ +// Location: ./crates/cpex-core/src/extensions/meta.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MetaExtension โ€” host-provided operational metadata. +// Mirrors cpex/framework/extensions/meta.py. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +/// Host-provided operational metadata. +/// +/// Carries entity identification (type + name) for route resolution, +/// operational tags for policy group inheritance, scope for +/// host-defined grouping, and arbitrary properties. +/// +/// Immutable โ€” set by the host before invoking the hook. Plugins +/// can read but not modify. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MetaExtension { + /// Entity type: "tool", "resource", "prompt", "llm". + /// Used by the manager for route resolution. + #[serde(default)] + pub entity_type: Option, + + /// Entity name: "get_compensation", "hr://employees/*", etc. + /// Used by the manager for route resolution. + #[serde(default)] + pub entity_name: Option, + + /// Operational tags โ€” drive policy group inheritance. + /// Merged with static tags from the matching route's `meta.tags`. + #[serde(default)] + pub tags: HashSet, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + #[serde(default)] + pub scope: Option, + + /// Arbitrary key-value metadata. + #[serde(default)] + pub properties: HashMap, +} diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs new file mode 100644 index 00000000..d51aec62 --- /dev/null +++ b/crates/cpex-core/src/extensions/mod.rs @@ -0,0 +1,52 @@ +// Location: ./crates/cpex-core/src/extensions/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed extension models for the CPEX framework. +// +// Each extension carries contextual metadata with an explicit +// mutability tier enforced by the processing pipeline. Extensions +// are always passed separately from the payload to handlers. +// +// Mirrors the Python extensions in cpex/framework/extensions/. + +pub mod agent; +pub mod completion; +pub mod container; +pub mod delegation; +pub mod filter; +pub mod framework; +pub mod guarded; +pub mod http; +pub mod llm; +pub mod mcp; +pub mod meta; +pub mod monotonic; +pub mod provenance; +pub mod request; +pub mod security; +pub mod tiers; + +// Re-export containers +pub use container::{Extensions, OwnedExtensions}; + +// Re-export all extension types +pub use agent::{AgentExtension, ConversationContext}; +pub use completion::{CompletionExtension, StopReason, TokenUsage}; +pub use delegation::{DelegationExtension, DelegationHop}; +pub use filter::{filter_extensions, SlotName}; +pub use framework::FrameworkExtension; +pub use guarded::{Guarded, WriteToken}; +pub use http::HttpExtension; +pub use llm::LLMExtension; +pub use mcp::{MCPExtension, PromptMetadata, ResourceMetadata, ToolMetadata}; +pub use meta::MetaExtension; +pub use monotonic::{DeclassifierToken, MonotonicSet}; +pub use provenance::ProvenanceExtension; +pub use request::RequestExtension; +pub use security::{ + AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, + SubjectExtension, SubjectType, +}; +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..82530199 --- /dev/null +++ b/crates/cpex-core/src/extensions/monotonic.rs @@ -0,0 +1,179 @@ +// 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..91d54c18 --- /dev/null +++ b/crates/cpex-core/src/extensions/security.rs @@ -0,0 +1,348 @@ +// 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 sec = SecurityExtension { + classification: Some("confidential".into()), + ..Default::default() + }; + assert_eq!(sec.classification.as_deref(), Some("confidential")); + } + + #[test] + fn test_subject_extension() { + let subject = SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: ["admin".to_string(), "hr".to_string()].into(), + permissions: ["read_all".to_string()].into(), + teams: ["engineering".to_string()].into(), + claims: [("iss".to_string(), "auth.example.com".to_string())].into(), + }; + assert_eq!(subject.id.as_deref(), Some("alice")); + assert_eq!(subject.subject_type, Some(SubjectType::User)); + assert!(subject.roles.contains("admin")); + assert!(subject.permissions.contains("read_all")); + assert!(subject.teams.contains("engineering")); + assert_eq!(subject.claims.get("iss").unwrap(), "auth.example.com"); + } + + #[test] + fn test_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/factory.rs b/crates/cpex-core/src/factory.rs new file mode 100644 index 00000000..3f49b819 --- /dev/null +++ b/crates/cpex-core/src/factory.rs @@ -0,0 +1,312 @@ +// Location: ./crates/cpex-core/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin factory registry. +// +// Provides a factory pattern for creating plugin instances from +// config. The host registers factories by `kind` name before +// loading config. When the manager processes a config file, it +// looks up the factory for each plugin's `kind` and calls create(). +// +// This decouples plugin instantiation from the manager โ€” the +// manager doesn't know how to create a "builtin" vs "wasm" vs +// "python" plugin. The factory does. +// +// Mirrors the Python framework's PluginLoader in +// cpex/framework/loader/plugin.py. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::error::PluginError; +use crate::plugin::{Plugin, PluginConfig}; +use crate::registry::AnyHookHandler; + +// --------------------------------------------------------------------------- +// Plugin Factory Trait +// --------------------------------------------------------------------------- + +/// Factory for creating plugin instances from config. +/// +/// The host registers factories by `kind` name before loading +/// config. When the manager processes a config file, it looks up +/// the factory for each plugin's `kind` and calls `create()`. +/// +/// The factory returns both the plugin and its handler because it +/// knows the concrete types โ€” which handler traits the plugin +/// implements and which hooks it handles. +/// +/// # Examples +/// +/// ```rust,ignore +/// struct RateLimiterFactory; +/// +/// impl PluginFactory for RateLimiterFactory { +/// fn create(&self, config: &PluginConfig) +/// -> Result> +/// { +/// let plugin = Arc::new(RateLimiter::from_config(config)?); +/// let handler = Arc::new(TypedHandlerAdapter::::new( +/// Arc::clone(&plugin), +/// )); +/// Ok(PluginInstance { plugin, handler }) +/// } +/// } +/// +/// let mut factories = PluginFactoryRegistry::new(); +/// factories.register("security/rate_limit", Box::new(RateLimiterFactory)); +/// ``` +pub trait PluginFactory: Send + Sync { + /// Create a plugin instance and its handler from config. + /// + /// The `config` is the plugin's entry from the YAML file. + fn create(&self, config: &PluginConfig) -> Result>; +} + +/// A created plugin instance โ€” the plugin and its type-erased handlers. +/// +/// Each handler is paired with the hook name it handles. A plugin +/// that implements multiple hook types (e.g., `ToolPreInvoke` and +/// `ToolPostInvoke`) returns one entry per hook. +pub struct PluginInstance { + /// The plugin implementation. + pub plugin: Arc, + + /// Type-erased handlers paired with their hook names. + /// Each entry maps a hook name to the adapter for that hook type. + pub handlers: Vec<(&'static str, Arc)>, +} + +// --------------------------------------------------------------------------- +// Plugin Factory Registry +// --------------------------------------------------------------------------- + +/// Registry of plugin factories keyed by `kind` name. +/// +/// The host populates this before calling `PluginManager::from_config()`. +/// Each factory knows how to create plugins of a specific kind. +/// +/// # Two dispatch modes +/// +/// Factories register under one of two patterns: +/// +/// * **Exact-match `kind`** โ€” `register("rate_limiter", factory)`. +/// Matches plugins whose `kind:` is exactly `"rate_limiter"`. This +/// is the standard pattern for in-tree factories. +/// * **Scheme prefix** โ€” `register_scheme("lib", factory)`. Matches +/// plugins whose `kind:` starts with `"lib:"` (e.g., +/// `kind: "lib:/opt/plugins/foo.so#bar"`). The factory's +/// `create()` receives the full kind string and parses the +/// scheme-specific format itself. Used by dynamic loaders +/// (cdylib, WASM, gRPC) where the kind needs to carry a +/// resource locator alongside the plugin name. +/// +/// Exact matches win over scheme matches when both are registered. +/// +/// # Examples +/// +/// ```rust,ignore +/// let mut factories = PluginFactoryRegistry::new(); +/// factories.register("rate_limiter", Box::new(RateLimiterFactory)); +/// factories.register_scheme("lib", Box::new(DynamicPluginFactory::new())); +/// +/// let manager = PluginManager::from_config(path, &factories)?; +/// ``` +pub struct PluginFactoryRegistry { + /// Factories registered for exact `kind` matches. + factories: HashMap>, + /// Factories registered for `:...` style kinds. The + /// key is the scheme alone (e.g., `"lib"`). + scheme_factories: HashMap>, +} + +impl PluginFactoryRegistry { + /// Create an empty factory registry. + pub fn new() -> Self { + Self { + factories: HashMap::new(), + scheme_factories: HashMap::new(), + } + } + + /// Register a factory for a given `kind` name (exact match). + pub fn register(&mut self, kind: impl Into, factory: Box) { + self.factories.insert(kind.into(), factory); + } + + /// Register a factory that handles all kinds starting with + /// `:`. The factory's `create()` receives the full + /// kind string (including the scheme prefix) and is + /// responsible for parsing the scheme-specific format. + /// + /// Example: `register_scheme("lib", ...)` matches plugins with + /// `kind: "lib:/path/to/foo.so"`, `kind: "lib:/other.so#handler"`, + /// etc. + pub fn register_scheme( + &mut self, + scheme: impl Into, + factory: Box, + ) { + self.scheme_factories.insert(scheme.into(), factory); + } + + /// Look up a factory by `kind` name. Tries exact match first; + /// falls back to scheme-prefix match if the kind contains a + /// `:` separator. + pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory> { + if let Some(f) = self.factories.get(kind) { + return Some(f.as_ref()); + } + if let Some((scheme, _rest)) = kind.split_once(':') { + if !scheme.is_empty() { + return self.scheme_factories.get(scheme).map(|f| f.as_ref()); + } + } + None + } + + /// Whether a factory exists for the given `kind` (exact or + /// scheme-prefix match). + pub fn has(&self, kind: &str) -> bool { + self.get(kind).is_some() + } + + /// All registered exact-match kind names. + pub fn kinds(&self) -> Vec<&str> { + self.factories.keys().map(|s| s.as_str()).collect() + } + + /// All registered scheme names (without the trailing `:`). + pub fn schemes(&self) -> Vec<&str> { + self.scheme_factories.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for PluginFactoryRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::PluginConfig; + + /// Fake factory that records a tag so tests can verify which + /// factory was dispatched to. `create()` always errors with the + /// tag embedded โ€” tests look at the error message instead of + /// constructing real PluginInstances. + struct TagFactory(&'static str); + impl PluginFactory for TagFactory { + fn create( + &self, + _config: &PluginConfig, + ) -> Result> { + Err(Box::new(PluginError::Config { + message: format!("dispatched-to:{}", self.0), + })) + } + } + + fn make_cfg(kind: &str) -> PluginConfig { + PluginConfig { + name: "test".into(), + kind: kind.into(), + ..Default::default() + } + } + + /// Pull the dispatch tag out of a TagFactory error. Uses match + /// instead of `unwrap_err()` because `PluginInstance` (the Ok + /// variant) holds `Arc` and doesn't impl Debug. + fn dispatch_tag(result: Result>) -> String { + match result { + Err(boxed) => match *boxed { + PluginError::Config { message } => message + .strip_prefix("dispatched-to:") + .map(String::from) + .unwrap_or(message), + _ => panic!("unexpected error variant"), + }, + Ok(_) => panic!("TagFactory should always Err"), + } + } + + #[test] + fn exact_match_dispatches_to_registered_factory() { + let mut reg = PluginFactoryRegistry::new(); + reg.register("rate_limit", Box::new(TagFactory("rate_limit"))); + let factory = reg.get("rate_limit").expect("factory found"); + assert_eq!(dispatch_tag(factory.create(&make_cfg("rate_limit"))), "rate_limit"); + } + + #[test] + fn unknown_kind_returns_none() { + let reg = PluginFactoryRegistry::new(); + assert!(reg.get("nonexistent").is_none()); + assert!(!reg.has("nonexistent")); + } + + #[test] + fn scheme_match_dispatches_when_no_exact_match() { + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("lib", Box::new(TagFactory("lib-loader"))); + // kind starts with `lib:` โ†’ dispatch to scheme factory. + let factory = reg.get("lib:/opt/plugins/foo.so#bar").expect("factory found"); + assert_eq!( + dispatch_tag(factory.create(&make_cfg("lib:/opt/plugins/foo.so#bar"))), + "lib-loader", + ); + } + + #[test] + fn exact_match_wins_over_scheme_match() { + let mut reg = PluginFactoryRegistry::new(); + reg.register("lib", Box::new(TagFactory("exact-lib"))); + reg.register_scheme("lib", Box::new(TagFactory("scheme-lib"))); + let exact = reg.get("lib").unwrap(); + assert_eq!(dispatch_tag(exact.create(&make_cfg("lib"))), "exact-lib"); + let prefixed = reg.get("lib:/path/to.so").unwrap(); + assert_eq!( + dispatch_tag(prefixed.create(&make_cfg("lib:/path/to.so"))), + "scheme-lib", + ); + } + + #[test] + fn empty_scheme_does_not_match() { + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("", Box::new(TagFactory("would-be-empty"))); + assert!( + reg.get(":foo").is_none(), + "leading-colon kind must not dispatch even when empty scheme is registered", + ); + } + + #[test] + fn kind_with_colons_in_path_dispatches_correctly() { + // Windows path with drive-letter colon: `lib:/C:/plugins/foo.dll`. + // `split_once(':')` splits on the FIRST colon only โ€” scheme is + // `"lib"`, rest with embedded colons passes through unchanged. + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("lib", Box::new(TagFactory("lib-loader"))); + let factory = reg.get("lib:/C:/plugins/foo.dll").unwrap(); + assert_eq!( + dispatch_tag(factory.create(&make_cfg("lib:/C:/plugins/foo.dll"))), + "lib-loader", + ); + } + + #[test] + fn schemes_lists_registered_schemes() { + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("lib", Box::new(TagFactory("a"))); + reg.register_scheme("wasm", Box::new(TagFactory("b"))); + let mut names: Vec<&str> = reg.schemes(); + names.sort(); + assert_eq!(names, vec!["lib", "wasm"]); + } +} diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs new file mode 100644 index 00000000..985108ca --- /dev/null +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -0,0 +1,126 @@ +// Location: ./crates/cpex-core/src/hooks/adapter.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// TypedHandlerAdapter โ€” bridges typed HookHandler to the +// type-erased AnyHookHandler. +// +// This is framework plumbing that plugin authors never see. When a +// plugin is registered via `manager.register_handler::()`, the +// manager creates a TypedHandlerAdapter internally. The adapter +// translates between Box (what the executor passes) +// and the concrete payload type (what the handler expects), and awaits +// the typed handler's future before re-erasing the result. +// +// `HookHandler` is async-by-default (native AFIT). Plugins that +// don't await anything still write `async fn handle(...)`; the +// compiler emits a trivially-ready future that LLVM inlines, so the +// `.await` here is a no-op for sync-style plugins. + +use std::marker::PhantomData; +use std::sync::Arc; + +use crate::context::PluginContext; +use crate::error::PluginError; +use crate::executor::erase_result; +use crate::hooks::payload::{Extensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::plugin::Plugin; +use crate::registry::AnyHookHandler; + +// --------------------------------------------------------------------------- +// 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. +/// +/// `HookHandler` is async (native AFIT), so the adapter awaits the +/// returned future before re-erasing the result. Plugins that don't +/// `.await` anything compile to a ready future that LLVM inlines, so +/// they pay no observable cost over a plain function call. +/// +/// # Type Parameters +/// +/// - `H` โ€” the hook type (implements `HookTypeDef`). +/// - `P` โ€” the plugin type (implements `Plugin + HookHandler`). +pub struct TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// The plugin instance. + plugin: Arc

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

) -> Self { + Self { + plugin, + _hook: PhantomData, + } + } +} + +#[async_trait::async_trait] +impl AnyHookHandler for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// Downcast the type-erased payload to the concrete type and call + /// the plugin's typed `handle()` method, awaiting the returned + /// future. + /// + /// The framework retains ownership of the payload โ€” the handler + /// receives a borrow (`&H::Payload`) and clones only if it needs + /// to modify. The result is erased back to `ErasedResultFields` + /// for the executor. + /// + /// For plugins whose body contains no `.await`, the compiler emits + /// a trivially-ready future and LLVM inlines this `.await` to a + /// direct return โ€” there is no observable runtime cost over a + /// plain function call. + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + let typed_ref: &H::Payload = + payload + .as_any() + .downcast_ref::() + .ok_or_else(|| PluginError::Config { + message: format!( + "payload type mismatch for hook '{}': expected {}", + H::NAME, + std::any::type_name::() + ), + })?; + + let result = self.plugin.handle(typed_ref, extensions, ctx).await; + let plugin_result: PluginResult = result.into(); + + Ok(erase_result(plugin_result)) + } + + fn hook_type_name(&self) -> &'static str { + H::NAME + } +} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs new file mode 100644 index 00000000..80012acf --- /dev/null +++ b/crates/cpex-core/src/hooks/macros.rs @@ -0,0 +1,70 @@ +// Location: ./crates/cpex-core/src/hooks/macros.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// define_hook! macro. +// +// Generates a HookTypeDef marker struct and trait implementation +// from a single declaration. This is the primary way to define new +// hooks โ€” both built-in (CMF, tool, prompt) and custom (rate +// limiting, deployment gates, federation sync). +// +// Plugins implement the generic HookHandler trait (from +// trait_def.rs) for the generated marker struct. The handler +// receives a borrowed payload and returns the hook's result type. + +/// Generates a hook type definition and marker struct. +/// +/// # Usage +/// +/// ```rust,ignore +/// define_hook! { +/// /// Doc comment for the hook. +/// MyHook, "my_hook" => { +/// payload: MyPayload, +/// result: PluginResult, +/// } +/// } +/// ``` +/// +/// This generates a marker struct `MyHook` implementing `HookTypeDef`. +/// Plugins handle it by implementing `HookHandler`. +/// +/// # CMF Pattern (one handler, multiple hook names) +/// +/// For CMF hooks where one handler covers multiple hook names: +/// +/// ```rust,ignore +/// define_hook! { +/// /// CMF message evaluation hook. +/// CmfHook, "cmf" => { +/// payload: MessagePayload, +/// result: PluginResult, +/// } +/// } +/// +/// // Register the same handler for multiple names: +/// // manager.register_handler_for_names::(plugin, config, &[ +/// // "cmf.tool_pre_invoke", "cmf.llm_input", ... +/// // ]); +/// ``` +#[macro_export] +macro_rules! define_hook { + ( + $(#[$meta:meta])* + $name:ident, $hook_name:literal => { + payload: $payload:ty, + result: $result:ty $(,)? + } + ) => { + $(#[$meta])* + pub struct $name; + + impl $crate::hooks::trait_def::HookTypeDef for $name { + type Payload = $payload; + type Result = $result; + const NAME: &'static str = $hook_name; + } + }; +} diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs new file mode 100644 index 00000000..e7fb48f3 --- /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. +// - [`Extensions`] โ€” capability-gated extension view passed to handlers. +// - [`define_hook!`] โ€” macro for declaring new hook types with handler traits. +// - [`hook_names`] / [`cmf_hook_names`] โ€” string constants for built-in hooks. +// +// Hook types are open โ€” hosts define their own using define_hook! alongside the built-ins. + +pub mod adapter; +pub mod macros; +pub mod payload; +pub mod trait_def; +pub mod types; + +// Re-export core types at the hooks level +pub use adapter::TypedHandlerAdapter; +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 new file mode 100644 index 00000000..d284bf4c --- /dev/null +++ b/crates/cpex-core/src/hooks/payload.rs @@ -0,0 +1,133 @@ +// Location: ./crates/cpex-core/src/hooks/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// PluginPayload trait and Extensions stub. +// +// PluginPayload is the base trait for all hook payloads, mirroring +// Python's PluginPayload(BaseModel, frozen=True). All payloads in +// the framework implement this trait, giving the executor and +// registry a common bound for type safety. +// +// The trait is object-safe โ€” the executor works with `Box` +// instead of `Box`, catching type errors at compile time. +// Downcasting to concrete types uses the `as_any()` method. +// +// Extensions is the typed container for all message extensions +// (security, delegation, HTTP, meta, etc.). It is always passed +// as a separate parameter to handlers โ€” never inside the payload. +// This allows per-plugin capability filtering and independent +// modification without copying the payload. + +use std::any::Any; +use std::fmt; + +// Re-export Extensions and OwnedExtensions from the extensions module. +// These are the typed containers for all extension data. They live in +// extensions/container.rs but are re-exported here for backward +// compatibility with existing code that imports from hooks::payload. +pub use crate::extensions::{Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken}; + +// --------------------------------------------------------------------------- +// 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 `&Extensions` parameter to handlers. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::payload::PluginPayload; +/// +/// #[derive(Debug, Clone)] +/// struct RateLimitPayload { +/// client_id: String, +/// request_count: u64, +/// } +/// +/// impl PluginPayload for RateLimitPayload { +/// fn clone_boxed(&self) -> Box { +/// Box::new(self.clone()) +/// } +/// fn as_any(&self) -> &dyn std::any::Any { self } +/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self } +/// } +/// ``` +pub trait PluginPayload: Send + Sync + 'static { + /// Clone this payload into a new `Box`. + /// + /// Used by the executor for copy-on-write: read-only modes borrow + /// the payload, modifying modes receive a clone via this method. + fn clone_boxed(&self) -> Box; + + /// Downcast to a concrete type via `&dyn Any`. + /// + /// Used by typed handler wrappers to recover the concrete payload + /// type from `Box`. + fn as_any(&self) -> &dyn Any; + + /// Downcast to a concrete type via `&mut dyn Any`. + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl fmt::Debug for dyn PluginPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("dyn PluginPayload") + } +} + +// --------------------------------------------------------------------------- +// 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..b75d2b8a --- /dev/null +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -0,0 +1,322 @@ +// Location: ./crates/cpex-core/src/hooks/trait_def.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HookTypeDef trait and PluginResult type. +// +// Every hook in the CPEX framework is defined by a marker type that +// implements HookTypeDef. This associates a typed PluginPayload and +// PluginResult with a string name used for registry lookup and config. +// +// The hook type does NOT declare an access pattern (read-only vs +// mutating). The plugin's mode (from PluginRef.trusted_config) +// determines scheduling and authority at runtime. Security invariants +// come from the types inside the payload (Arc, MonotonicSet, +// Guarded), not from borrow mechanics. +// +// Extensions are always a separate parameter โ€” never part of the +// payload. This allows capability-filtered views per plugin and +// independent modification of extensions without copying the payload. + +use crate::context::PluginContext; +use crate::error::PluginViolation; +use crate::hooks::payload::{Extensions, PluginPayload}; +use crate::plugin::Plugin; + +// --------------------------------------------------------------------------- +// 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. +/// +/// # Async by design +/// +/// `handle` is an `async fn`. Plugins that don't need to `.await` +/// anything still write `async fn handle(...)` and return synchronously +/// โ€” the compiler emits a trivially-ready future and LLVM inlines it +/// at the adapter site, so there's no observable runtime cost over a +/// plain function. Plugins that *do* need to `.await` (fresh JWKS +/// fetch, RPC to an authz service, dynamic policy lookup) just use +/// `.await` inside the body. +/// +/// **Best practice:** even when async is available, prefer pre-loading +/// state in [`Plugin::initialize`] and reading from cache in `handle`. +/// Hot-path I/O is the most common source of latency regressions. +/// +/// # Native AFIT, not `#[async_trait]` +/// +/// The trait uses native `async fn` (return-position `impl Future`) +/// rather than `#[async_trait]`. This avoids a per-call heap +/// allocation: the returned future is monomorphized into the +/// [`TypedHandlerAdapter`] rather than boxed. The trait is therefore +/// **not object-safe** โ€” you cannot have `Box>`. +/// We don't need that; type erasure happens one layer up at +/// [`AnyHookHandler`]. +/// +/// # Examples +/// +/// ```rust,ignore +/// // Synchronous plugin โ€” no .await, no extra cost +/// impl HookHandler for AllowPlugin { +/// async fn handle( +/// &self, +/// _payload: &MessagePayload, +/// _extensions: &Extensions, +/// _ctx: &mut PluginContext, +/// ) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// +/// // Async plugin โ€” calls .await inside the body +/// impl HookHandler for AuthzPlugin { +/// async fn handle( +/// &self, +/// payload: &MyPayload, +/// _extensions: &Extensions, +/// _ctx: &mut PluginContext, +/// ) -> PluginResult { +/// match self.client.check(&payload.user).await { +/// Ok(true) => PluginResult::allow(), +/// _ => PluginResult::deny(/* ... */), +/// } +/// } +/// } +/// +/// // Registration is the same for both: +/// manager.register_handler::(plugin, config)?; +/// ``` +/// +/// [`PluginManager::register_handler`]: crate::manager::PluginManager::register_handler +/// [`AnyHookHandler`]: crate::registry::AnyHookHandler +/// [`TypedHandlerAdapter`]: crate::hooks::adapter::TypedHandlerAdapter +pub trait HookHandler: Plugin + Send + Sync { + /// Handle the hook invocation. + /// + /// Receives a **borrow** of the typed payload, capability-filtered + /// extensions, and per-invocation context. Returns a typed result. + /// + /// The payload is immutable โ€” Rust's borrow checker prevents + /// modification through `&H::Payload`. To modify, the plugin + /// must `clone()` the payload (or the fields it needs) and return + /// the modified copy in `PluginResult::modify_payload()`. This + /// pushes the clone cost to the plugin that actually needs it โ€” + /// read-only plugins (validators, auditors) never pay for a copy. + /// + /// Returns a `Send`-able future so the executor can drive it from + /// any worker thread (including the concurrent-phase `JoinSet`). + /// `H::Result` is already `Send + Sync` per the `HookTypeDef` + /// bound, so the `Send` constraint comes for free for typical + /// handlers. + fn handle( + &self, + payload: &H::Payload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> impl std::future::Future + Send; +} + +// --------------------------------------------------------------------------- +// 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)] +pub struct PluginResult { + /// Whether the pipeline should continue processing. + /// `false` halts the pipeline (deny). Only respected for + /// Sequential and Concurrent modes. + pub continue_processing: bool, + + /// Modified payload. `None` means no content modification. + /// Only accepted from Sequential and Transform mode plugins. + pub modified_payload: Option

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

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

{ + fn default() -> Self { + Self::allow() + } +} diff --git a/crates/cpex-core/src/hooks/types.rs b/crates/cpex-core/src/hooks/types.rs 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..f2f8f80c --- /dev/null +++ b/crates/cpex-core/src/lib.rs @@ -0,0 +1,35 @@ +// Location: ./crates/cpex-core/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Core library root. +// +// Pure Rust plugin runtime with no FFI, WASM, or PyO3 dependencies. +// Provides the PluginManager, 5-phase executor, hook registry, +// unified config parser, and all core types. +// +// # Modules +// +// - [`plugin`] โ€” Plugin trait, PluginRef, PluginMetadata, PluginConfig +// - [`hooks`] โ€” HookType (open string registry), payload/result traits +// - [`executor`] โ€” 5-phase execution engine (sequential โ†’ transform โ†’ audit โ†’ concurrent โ†’ fire_and_forget) +// - [`manager`] โ€” PluginManager lifecycle and hook dispatch +// - [`registry`] โ€” PluginInstanceRegistry and HookRegistry +// - [`config`] โ€” Unified YAML configuration parsing +// - [`factory`] โ€” Plugin factory registry for config-driven instantiation +// - [`context`] โ€” PluginContext (local_state + global_state) +// - [`cmf`] โ€” ContextForge Message Format (Message, ContentPart, enums) +// - [`error`] โ€” Error types, violations, and result types + +pub mod cmf; +pub mod config; +pub mod context; +pub mod error; +pub mod executor; +pub mod extensions; +pub mod factory; +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..4bc1067a --- /dev/null +++ b/crates/cpex-core/src/manager.rs @@ -0,0 +1,4996 @@ +// 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::hash::{Hash, Hasher}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; + +use hashbrown::HashMap; +use tracing::{error, info, warn}; + +use crate::config::{self, CpexConfig}; +use crate::context::PluginContextTable; +use crate::error::PluginError; +use crate::executor::{BackgroundTasks, Executor, ExecutorConfig, PipelineResult}; +use crate::factory::PluginFactoryRegistry; +use crate::hooks::adapter::TypedHandlerAdapter; +use crate::hooks::payload::{Extensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig}; +use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry}; + +// --------------------------------------------------------------------------- +// Manager Configuration +// --------------------------------------------------------------------------- + +/// Default upper bound on the routing cache. Caps memory growth from +/// attacker-controlled entity names without forcing operators to tune. +pub const DEFAULT_ROUTE_CACHE_MAX_ENTRIES: usize = 10_000; + +/// Configuration for the PluginManager. +#[derive(Debug, Clone)] +pub struct ManagerConfig { + /// Executor configuration (timeout, short-circuit behavior). + pub executor: ExecutorConfig, + + /// Maximum number of entries in the routing cache. When the cache + /// reaches this size, further inserts are rejected (with a one-shot + /// warn log) and resolutions fall back to the slow path. See + /// `PluginSettings::route_cache_max_entries` for the YAML surface. + pub route_cache_max_entries: usize, +} + +impl Default for ManagerConfig { + fn default() -> Self { + Self { + executor: ExecutorConfig::default(), + route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, + } + } +} + +// --------------------------------------------------------------------------- +// 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. +/// Cache key for resolved routing entries. +/// +/// Includes entity type, name, hook name, and scope so that +/// the same tool on different scopes or at different hook points +/// caches separately. +/// +/// Custom Hash/Eq implementations hash on `&str` slices so that +/// `raw_entry` lookups with borrowed strings produce the same hash +/// as the owned key โ€” enabling zero-allocation cache hits. +#[derive(Debug, Clone)] +struct RouteCacheKey { + entity_type: String, + entity_name: String, + hook_name: String, + scope: Option, +} + +impl Hash for RouteCacheKey { + fn hash(&self, state: &mut H) { + self.entity_type.as_str().hash(state); + self.entity_name.as_str().hash(state); + self.hook_name.as_str().hash(state); + self.scope.as_deref().hash(state); + } +} + +impl PartialEq for RouteCacheKey { + fn eq(&self, other: &Self) -> bool { + self.entity_type == other.entity_type + && self.entity_name == other.entity_name + && self.hook_name == other.hook_name + && self.scope == other.scope + } +} + +impl Eq for RouteCacheKey {} + +/// Mutable runtime state held atomically swappable behind `ArcSwap`. +/// +/// Every read on the hot path (`invoke_*`) does a single atomic load to +/// get an `Arc` โ€” no locks. Mutating operations +/// (`register_*`, `load_config`, `unregister`) clone the current snapshot, +/// mutate the clone, and atomically swap the new `Arc` in. Old readers +/// finish on the old snapshot; new readers see the new one. This is the +/// classic Read-Copy-Update / RCU pattern: lock-free reads, copy-on-write +/// writes, no reader-writer contention. +/// +/// Cloning `PluginRegistry` is cheap because every value inside (`PluginRef`, +/// `AnyHookHandler`) is `Arc`-counted โ€” only the `HashMap` shells duplicate. +#[derive(Clone)] +struct RuntimeSnapshot { + /// Plugin registry โ€” stores PluginRefs and hook-to-handler mappings. + registry: PluginRegistry, + + /// Executor โ€” stateless 5-phase pipeline engine. + executor: Executor, + + /// Parsed CPEX config (when loaded from file). Used for route resolution. + cpex_config: Option, + + /// Maximum number of entries the route cache will hold. Once reached, + /// new resolutions are computed normally but not memoized (reject-on-full). + route_cache_max_entries: usize, +} + +pub struct PluginManager { + /// Hot-path runtime state. Swapped atomically on registration / config + /// reload โ€” readers see a consistent view via a single `load_full()`. + runtime: arc_swap::ArcSwap, + + /// Factory registry โ€” owned by the manager. Used for initial + /// instantiation and for creating override instances when routes + /// override a plugin's base config. + /// + /// Held in a `RwLock` rather than the `ArcSwap` snapshot because + /// `Box` is not `Clone`. Read on the slow path + /// (route cache miss + override config); write on `register_factory`. + /// The hot path never touches it. + factories: RwLock, + + /// Cache of resolved hook entries per (entity, hook, scope). + /// Populated on first access, invalidated on config reload. + /// Uses Arc so cache reads are refcount bumps (~1ns), not data copies. + route_cache: RwLock>>>, + + /// Hasher builder for zero-allocation cache lookups via raw_entry. + cache_hasher: hashbrown::DefaultHashBuilder, + + /// Set to true after the first time the cache rejects an insert in a + /// given fill cycle, so the warn log fires once per cycle rather than + /// on every miss under DoS. Reset by `clear_routing_cache()`. + route_cache_full_warned: AtomicBool, + + /// Whether initialize() has been called. Atomic so lifecycle methods + /// can be `&self` and the manager itself can sit behind `Arc`. + initialized: AtomicBool, + + /// Tracks in-flight fire-and-forget background tasks across all + /// invocations so `shutdown()` can wait for them to drain before + /// returning. Without this, audit/telemetry tasks spawned by recent + /// invokes get cancelled when the runtime tears down. Tasks are + /// `tracker.spawn`'d in `spawn_fire_and_forget`; `shutdown()` calls + /// `close().wait().await`. + /// + /// `TaskTracker` is internally `Arc`'d, so cloning is a refcount bump. + task_tracker: tokio_util::task::TaskTracker, +} + +/// Emit warnings for YAML settings that the runtime doesn't currently +/// honor. Called once per `load_config` / `from_config` so operators +/// who set these knobs aren't silently ignored. +/// +/// `user_patterns` / `content_types` on `PluginCondition` are not warned +/// โ€” they were wired up alongside this fix and now actually filter. +fn warn_on_inactive_settings(cfg: &CpexConfig) { + if !cfg.plugin_dirs.is_empty() { + warn!( + "config sets `plugin_dirs` (count={}) but the runtime does not \ + scan directories for plugins โ€” plugins must be registered via \ + `register_factory()` and listed under `plugins:`. Setting ignored.", + cfg.plugin_dirs.len(), + ); + } + if cfg.plugin_settings.parallel_execution_within_band { + warn!( + "config sets `plugin_settings.parallel_execution_within_band: true` \ + but the runtime does not honor it โ€” use `mode: concurrent` on \ + individual plugins for parallel execution. Setting ignored.", + ); + } + if cfg.plugin_settings.fail_on_plugin_error { + warn!( + "config sets `plugin_settings.fail_on_plugin_error: true` but the \ + runtime does not honor it โ€” use per-plugin `on_error: fail` for \ + that behavior. Setting ignored.", + ); + } +} + +/// Instantiate every plugin in `plugin_configs` via the matching factory +/// and register the resulting handlers into `target_registry`. Shared by +/// `PluginManager::from_config` (fresh registry) and `load_config` (clone +/// of the existing registry) so the instantiation loop lives in one place. +/// +/// Returns on the first failure (factory missing, factory.create error, or +/// duplicate-name registration). On error, `target_registry` is in a +/// partial state โ€” both callers discard it on failure (load_config builds +/// the new registry on a clone and only swaps on Ok; from_config bails +/// before publishing the snapshot). +fn instantiate_plugins_into( + target_registry: &mut PluginRegistry, + plugin_configs: &[crate::plugin::PluginConfig], + factories: &PluginFactoryRegistry, +) -> Result<(), Box> { + for plugin_config in plugin_configs { + let factory = factories + .get(&plugin_config.kind) + .ok_or_else(|| PluginError::Config { + message: format!( + "no factory registered for plugin kind '{}' (plugin '{}')", + plugin_config.kind, plugin_config.name + ), + })?; + + let instance = factory.create(plugin_config)?; + + target_registry + .register_multi_handler(instance.plugin, plugin_config.clone(), instance.handlers) + .map_err(|msg| Box::new(PluginError::Config { message: msg }))?; + + info!( + "Registered plugin '{}' (kind: '{}') for hooks: {:?}", + plugin_config.name, plugin_config.kind, plugin_config.hooks + ); + } + Ok(()) +} + +/// Build a `RuntimeSnapshot` from a populated registry plus the YAML +/// settings on `cpex_config`. Pulls executor timeout / short-circuit and +/// the route-cache cap from `plugin_settings` so both registration paths +/// agree on field-by-field translation. +fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> RuntimeSnapshot { + let executor = Executor::new(ExecutorConfig { + timeout_seconds: cpex_config.plugin_settings.plugin_timeout, + short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, + }); + let route_cache_max_entries = cpex_config.plugin_settings.route_cache_max_entries; + RuntimeSnapshot { + registry, + executor, + cpex_config: Some(cpex_config), + route_cache_max_entries, + } +} + +impl PluginManager { + /// Create a new PluginManager with the given configuration. + pub fn new(config: ManagerConfig) -> Self { + let cache_hasher = hashbrown::DefaultHashBuilder::default(); + let snapshot = RuntimeSnapshot { + registry: PluginRegistry::new(), + executor: Executor::new(config.executor), + cpex_config: None, + route_cache_max_entries: config.route_cache_max_entries, + }; + Self { + runtime: arc_swap::ArcSwap::from_pointee(snapshot), + factories: RwLock::new(PluginFactoryRegistry::new()), + route_cache: RwLock::new(HashMap::with_hasher(cache_hasher)), + cache_hasher, + route_cache_full_warned: AtomicBool::new(false), + initialized: AtomicBool::new(false), + task_tracker: tokio_util::task::TaskTracker::new(), + } + } + + /// Load the current runtime snapshot (lock-free, single atomic op). + fn load_runtime(&self) -> Arc { + self.runtime.load_full() + } + + /// Apply a mutation to the runtime snapshot via copy-on-write. + /// Clones the current snapshot, runs the closure on the clone, and + /// atomically swaps it in. Concurrent readers continue using the old + /// snapshot; subsequent readers see the new one. + fn mutate_runtime(&self, f: F) -> R + where + F: FnOnce(&mut RuntimeSnapshot) -> R, + { + let current = self.runtime.load_full(); + let mut next = (*current).clone(); + let result = f(&mut next); + self.runtime.store(Arc::new(next)); + result + } + + /// Like `mutate_runtime` but the mutation can fail โ€” the new snapshot + /// is only published on `Ok`. On `Err`, the original snapshot is + /// untouched, so a partially-mutated clone is silently discarded. + fn try_mutate_runtime(&self, f: F) -> Result + where + F: FnOnce(&mut RuntimeSnapshot) -> Result, + { + let current = self.runtime.load_full(); + let mut next = (*current).clone(); + let result = f(&mut next)?; + self.runtime.store(Arc::new(next)); + Ok(result) + } + + // ----------------------------------------------------------------------- + // Factory Registration + // ----------------------------------------------------------------------- + + /// Register a plugin factory for a given `kind` name. + /// + /// The host calls this to tell the manager how to create plugins + /// of a specific kind. Must be called before `load_config()`. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut manager = PluginManager::default(); + /// manager.register_factory("builtin", Box::new(BuiltinFactory)); + /// manager.register_factory("security/rate_limit", Box::new(RateLimiterFactory)); + /// manager.load_config(Path::new("plugins.yaml"))?; + /// ``` + pub fn register_factory( + &self, + kind: impl Into, + factory: Box, + ) { + self.factories + .write() + .unwrap_or_else(|p| p.into_inner()) + .register(kind, factory); + } + + /// Register a factory that handles all plugin `kind`s starting + /// with `:`. Used by dynamic loaders (cdylib, WASM, + /// gRPC) where the kind string carries a resource locator + /// alongside the plugin name โ€” e.g. + /// `kind: "lib:/opt/plugins/foo.so#bar"`. + /// + /// The factory's `create()` receives the full kind string + /// (including the scheme prefix) and is responsible for + /// parsing the scheme-specific format. + /// + /// Exact-match `register_factory` registrations win over + /// scheme matches when both could apply. + /// + /// # Examples + /// + /// ```rust,ignore + /// // Once at host startup: + /// manager.register_factory_scheme( + /// "lib", + /// Box::new(DynamicPluginFactory::new()), + /// ); + /// + /// // Operators then write in unified-config YAML: + /// // plugins: + /// // - name: rate-limit + /// // kind: "lib:/opt/plugins/rate_limit.so#default" + /// ``` + pub fn register_factory_scheme( + &self, + scheme: impl Into, + factory: Box, + ) { + self.factories + .write() + .unwrap_or_else(|p| p.into_inner()) + .register_scheme(scheme, 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(&self, path: &Path) -> Result<(), Box> { + let cpex_config = config::load_config(path)?; + self.load_config(cpex_config) + } + + /// Load plugins from a parsed config. + /// + /// Looks up each plugin's `kind` in the factory registry, + /// instantiates the plugins, and registers them with their + /// hook names from the config. + pub fn load_config(&self, cpex_config: CpexConfig) -> Result<(), Box> { + warn_on_inactive_settings(&cpex_config); + + // Build the new snapshot from the current one โ€” copy-on-write so + // concurrent invokes keep using the existing config until we swap. + // We can't use mutate_runtime here because we need to atomically + // ALSO build a new executor + new cache cap from the same config โ€” + // the snapshot fields are coupled. + let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); + let current = self.runtime.load_full(); + let mut new_registry = current.registry.clone(); + + instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, &factories)?; + + // Drop the factories read lock before taking other locks + // (route_cache write below) to avoid lock-ordering hazards. + drop(factories); + + self.runtime + .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); + + // Clear routing cache โ€” config changed. + self.clear_routing_cache(); + + Ok(()) + } + + /// Create a PluginManager from a parsed config (convenience). + /// + /// Uses the passed factory registry for initial instantiation. + /// Note: for route-level config overrides to create new instances + /// at runtime, use `register_factory()` + `load_config()` instead + /// so the manager owns the factories. + pub fn from_config( + cpex_config: CpexConfig, + factories: &PluginFactoryRegistry, + ) -> Result> { + warn_on_inactive_settings(&cpex_config); + + let manager = Self::new(ManagerConfig { + executor: ExecutorConfig::default(), + route_cache_max_entries: cpex_config.plugin_settings.route_cache_max_entries, + }); + + // Instantiate into a fresh registry, then publish atomically. + let mut new_registry = PluginRegistry::new(); + instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, factories)?; + + manager + .runtime + .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); + + Ok(manager) + } + + // ----------------------------------------------------------------------- + // 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( + &self, + plugin: Arc

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

, + config: PluginConfig, + names: &[&str], + ) -> Result<(), Box> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, + { + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + self.try_mutate_runtime(|snap| { + snap.registry + .register_for_names::(plugin, config, handler, names) + .map_err(|msg| Box::new(PluginError::Config { message: msg })) + })?; + self.clear_routing_cache(); + Ok(()) + } + + /// Register with an explicit AnyHookHandler (advanced use). + /// + /// For cases where the automatic adapter doesn't fit โ€” e.g., + /// Python/WASM bridge hosts that implement AnyHookHandler directly. + /// Most callers should use `register_handler` instead. + pub fn register_raw( + &self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), Box> { + self.try_mutate_runtime(|snap| { + snap.registry + .register::(plugin, config, handler) + .map_err(|msg| Box::new(PluginError::Config { message: msg })) + })?; + self.clear_routing_cache(); + Ok(()) + } + + // ----------------------------------------------------------------------- + // 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(&self) -> Result<(), Box> { + if self.initialized.load(Ordering::Acquire) { + return Ok(()); + } + + // Snapshot once at start โ€” subsequent registrations don't affect + // this initialize() call. They'd need their own initialize. + let snapshot = self.load_runtime(); + + info!( + "Initializing PluginManager with {} plugins", + snapshot.registry.plugin_count() + ); + + let mut initialized_plugins: Vec = Vec::new(); + + for name in snapshot.registry.plugin_names() { + if let Some(plugin_ref) = snapshot.registry.get(&name) { + let plugin = plugin_ref.plugin().clone(); + let plugin_name = name; + + if let Err(e) = plugin.initialize().await { + error!("Failed to initialize plugin '{}': {}", plugin_name, e); + + // Clean up already-initialized plugins + for init_name in initialized_plugins.iter().rev() { + if let Some(pr) = snapshot.registry.get(init_name) { + if let Err(shutdown_err) = pr.plugin().shutdown().await { + error!( + "Error shutting down plugin '{}' during rollback: {}", + init_name, shutdown_err + ); + } + } + } + + return Err(Box::new(PluginError::Execution { + plugin_name, + message: format!("initialization failed: {}", e), + source: Some(Box::new(e)), + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, + })); + } + + initialized_plugins.push(plugin_name); + } + } + + self.initialized.store(true, Ordering::Release); + info!("PluginManager initialized successfully"); + Ok(()) + } + + /// Shutdown all registered plugins. + /// + /// Calls `plugin.shutdown()` on each registered plugin in reverse + /// registration order. Errors are logged but do not halt the + /// shutdown process โ€” all plugins get a chance to clean up. + /// Shut the manager down. **Terminal:** after `shutdown()` returns, + /// no further `register_*` / `invoke_*` should be called. New + /// fire-and-forget tasks spawned after `close()` will not be tracked + /// (the `TaskTracker` is single-shot by design). + pub async fn shutdown(&self) { + if !self.initialized.load(Ordering::Acquire) { + return; + } + + info!("Shutting down PluginManager"); + + // Drain in-flight fire-and-forget tasks BEFORE tearing down + // plugins โ€” otherwise audit/telemetry tasks that depend on the + // plugin being alive (or the runtime being up) get cancelled + // mid-flight. `close()` prevents new tasks from being tracked + // (existing in-flight ones still complete); `wait()` returns + // when the in-flight count drops to zero. + self.task_tracker.close(); + self.task_tracker.wait().await; + + let snapshot = self.load_runtime(); + for name in snapshot.registry.plugin_names() { + if let Some(plugin_ref) = snapshot.registry.get(&name) { + let plugin = plugin_ref.plugin().clone(); + + if let Err(e) = plugin.shutdown().await { + error!("Error shutting down plugin '{}': {}", name, e); + // Continue โ€” don't let one plugin's failure block others + } + } + } + + self.initialized.store(false, Ordering::Release); + info!("PluginManager shutdown complete"); + } + + // ----------------------------------------------------------------------- + // Hook Invocation โ€” Dynamic (invoke_by_name) + // ----------------------------------------------------------------------- + + /// Invoke a hook by name with a type-erased payload. + /// + /// This is the dynamic dispatch path used by Python/Go/WASM + /// callers via FFI or PyO3 bindings. The hook name is resolved + /// from the registry and dispatched through the 5-phase executor. + /// + /// # Arguments + /// + /// * `hook_name` โ€” the hook name string (e.g., `"cmf.tool_pre_invoke"`). + /// * `payload` โ€” the payload as `Box`. + /// * `extensions` โ€” the full extensions (filtered per plugin by the executor). + /// * `context_table` โ€” optional context table from a previous hook + /// invocation. Pass `None` on the first hook call; thread the + /// returned table into subsequent calls to preserve per-plugin state. + /// + /// # Returns + /// + /// A tuple of `(PipelineResult, BackgroundTasks)`. The result + /// contains the final payload, extensions, violation, and context + /// table. Background tasks can be awaited or dropped. + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks) { + // Single atomic load โ€” own the snapshot for the rest of the call so + // a concurrent register/load_config swapping in a new snapshot doesn't + // change our view mid-pipeline. + let snapshot = self.load_runtime(); + let hook_type = HookType::new(hook_name); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); + + if all_entries.is_empty() { + return ( + PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + let entries = self + .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name) + .await; + + if entries.is_empty() { + return ( + PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + snapshot + .executor + .execute( + &entries, + payload, + extensions, + context_table, + &self.task_tracker, + ) + .await + } + + // ----------------------------------------------------------------------- + // 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()`. + /// + /// When routing is enabled, the entity is identified from + /// `extensions.meta` (entity_type + entity_name). Only plugins + /// matching the resolved route fire. When routing is disabled + /// or meta is absent, all registered plugins fire. + /// + /// # Type Parameters + /// + /// - `H` โ€” the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// * `payload` โ€” the typed payload. + /// * `extensions` โ€” the full extensions (includes meta for routing). + /// * `context_table` โ€” optional context table from a previous hook. + /// + /// # Returns + /// + /// A tuple of `(PipelineResult, BackgroundTasks)`. + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks) { + let snapshot = self.load_runtime(); + let hook_type = HookType::new(H::NAME); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); + + 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(&snapshot, all_entries, &extensions, H::NAME) + .await; + + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), + BackgroundTasks::empty(), + ); + } + + let boxed: Box = Box::new(payload); + snapshot + .executor + .execute( + &entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) + .await + } + + /// Invoke a typed hook by explicit name. + /// + /// Combines compile-time payload type checking (from `H`) with + /// runtime hook name routing (from `hook_name`). Use this when + /// a single hook type (e.g., `CmfHook`) covers multiple hook + /// names (e.g., `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`). + /// + /// # Type Parameters + /// + /// - `H` โ€” the hook type (provides payload type checking). + /// + /// # Arguments + /// + /// * `hook_name` โ€” the hook name for dispatch routing. + /// * `payload` โ€” the typed payload (compile-time checked against `H::Payload`). + /// * `extensions` โ€” the full extensions. + /// * `context_table` โ€” optional context table from a previous hook. + /// + /// # Examples + /// + /// ```rust,ignore + /// // Compile-time: payload must be MessagePayload (from CmfHook) + /// // Runtime: dispatches to plugins registered under "cmf.tool_pre_invoke" + /// let (result, bg) = mgr.invoke_named::( + /// "cmf.tool_pre_invoke", payload, ext, None, + /// ).await; + /// ``` + pub async fn invoke_named( + &self, + hook_name: &str, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks) { + let snapshot = self.load_runtime(); + let hook_type = HookType::new(hook_name); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); + + 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(&snapshot, all_entries, &extensions, hook_name) + .await; + + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), + BackgroundTasks::empty(), + ); + } + + let boxed: Box = Box::new(payload); + snapshot + .executor + .execute( + &entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) + .await + } + + // ----------------------------------------------------------------------- + // 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. + async fn filter_entries_by_route( + &self, + snapshot: &RuntimeSnapshot, + entries: &[crate::registry::HookEntry], + extensions: &Extensions, + hook_name: &str, + ) -> Arc> { + // Routing disabled (or no config): fall back to per-plugin + // condition filtering. Empty conditions Vec means "fire always", + // so this is backward-compatible with configs that don't use + // conditions. Mirrors the Python implementation. + let cpex_config = match &snapshot.cpex_config { + Some(c) if c.routing_enabled() => c, + _ => { + let filtered: Vec<_> = entries + .iter() + .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions)) + .cloned() + .collect(); + return Arc::new(filtered); + } + }; + + // 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() + }; + { + // Recover from poisoning: a panic in another thread while holding + // this lock leaves the cache flagged poisoned. The cache's contents + // are still valid (HashMap operations are panic-safe and stale + // entries are healed by `clear_routing_cache()`), so we don't want + // a one-time panic to permanently disable dispatch. Same idiom + // applies to all four lock sites in this file. + let cache = self + .route_cache + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| { + key.entity_type == entity_type + && key.entity_name == entity_name + && key.hook_name == hook_name + && key.scope.as_deref() == request_scope + }) { + return Arc::clone(cached); + } + } + + // Slow path: resolve, filter, and cache (allocations only here) + 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).await + { + filtered.push(override_entry); + continue; + } + } + filtered.push(entry.clone()); + } + } + + let cached = Arc::new(filtered); + + // Store in cache โ€” owned key allocated only on cache miss. + // Reject-on-full: when the cache is at capacity we still return + // the freshly resolved Vec but skip memoization, bounding memory + // growth from attacker-controlled entity names. + let cache_key = RouteCacheKey { + entity_type: entity_type.to_string(), + entity_name: entity_name.to_string(), + hook_name: hook_name.to_string(), + scope: meta.scope.clone(), + }; + // Decide under the lock; log outside it so I/O doesn't block readers. + // One warn per fill cycle โ€” prevents log spam under DoS. + let should_warn = { + let mut cache = self + .route_cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if cache.len() >= snapshot.route_cache_max_entries { + !self.route_cache_full_warned.swap(true, Ordering::AcqRel) + } else { + cache.insert(cache_key, Arc::clone(&cached)); + false + } + }; + if should_warn { + warn!( + max_entries = snapshot.route_cache_max_entries, + "Routing cache at capacity โ€” further routes will not be cached. \ + Increase plugin_settings.route_cache_max_entries or \ + investigate entity name growth.", + ); + } + + cached + } + + /// Create an override plugin instance with merged config. + /// + /// When a route overrides a plugin's config, we create a new + /// instance via the factory with the merged config and call + /// `initialize()` on it so plugins that open DB connections / file + /// handles / network clients run their setup. + /// + /// The override gets its OWN circuit breaker (`disabled` flag) and + /// its own UUID, independent of the base. Config is part of the + /// failure surface โ€” an override with a bad connection string / + /// wrong credentials / wrong limit value can fail for reasons that + /// have nothing to do with the base's reliability. Coupling them + /// would let a config-specific failure on one route silently + /// disable the plugin on every other route, which is the opposite + /// of the per-route blast-radius guarantee operators reach for + /// overrides to get. The fresh UUID also keys the override's + /// `local_state` in the context table, isolating per-instance + /// state from the base for the same reason. + /// + /// Returns `None` (and the caller falls back to the base entry) if: + /// - no factory is available for the plugin's kind, + /// - the factory fails to create the instance, + /// - the new instance has no handler for the target hook, + /// - or `initialize()` fails on the new instance. + async fn create_override_instance( + &self, + base_entry: &crate::registry::HookEntry, + overrides: &serde_json::Value, + ) -> Option { + let base_config = base_entry.plugin_ref.trusted_config(); + let kind = &base_config.kind; + + // Merge: start with base config, overlay with overrides + let mut merged_config = base_config.clone(); + if let Some(override_config) = overrides.get("config") { + // Merge the plugin-specific config section + if let Some(base_plugin_config) = &merged_config.config { + let mut merged = base_plugin_config.clone(); + if let (Some(base_obj), Some(override_obj)) = + (merged.as_object_mut(), override_config.as_object()) + { + for (key, value) in override_obj { + base_obj.insert(key.clone(), value.clone()); + } + } + merged_config.config = Some(merged); + } else { + merged_config.config = Some(override_config.clone()); + } + } + + // Create new instance with merged config โ€” hold the factories + // read lock just long enough to construct the instance, then drop + // it before any `.await` so we never hold a sync lock across awaits. + let target_hook = base_entry.handler.hook_type_name(); + let instance = { + let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); + let factory = match factories.get(kind) { + Some(f) => f, + None => return None, + }; + match factory.create(&merged_config) { + Ok(i) => i, + Err(e) => { + error!( + "Failed to create override instance for '{}': {}", + base_config.name, e + ); + return None; // fall back to base instance + } + } + }; + + // Find the handler matching the current hook before consuming + // the instance so we don't pay for initialization on a doomed instance. + let handler = instance + .handlers + .into_iter() + .find(|(name, _)| *name == target_hook) + .map(|(_, h)| h); + let handler = match handler { + Some(h) => h, + None => { + warn!( + "Override instance for '{}' has no handler for hook '{}'", + base_config.name, target_hook + ); + return None; + } + }; + + // Initialize the new instance โ€” without this, plugins that need to + // set up DB connections / file handles / network clients run with + // default state. + if let Err(e) = instance.plugin.initialize().await { + error!( + "Failed to initialize override instance for '{}': {} โ€” falling back to base", + base_config.name, e + ); + return None; + } + + // Independent circuit breaker + fresh UUID per (kind, name, config) + // โ€” see the doc comment above for why we don't share with the base. + // Arc-wrapped for cheap cloning under group_by_mode. + let plugin_ref = Arc::new(crate::registry::PluginRef::new( + instance.plugin, + merged_config, + )); + Some(crate::registry::HookEntry { + plugin_ref, + handler, + }) + } + + /// Clear the routing cache. Call when config is reloaded or + /// plugins are registered/unregistered. Also resets the + /// "cache full" warn-once latch so the next fill cycle can warn again. + pub fn clear_routing_cache(&self) { + let mut cache = self + .route_cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache.clear(); + self.route_cache_full_warned.store(false, Ordering::Release); + } + + /// Number of entries in the routing cache. + pub fn routing_cache_size(&self) -> usize { + self.route_cache + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } + + // ----------------------------------------------------------------------- + // Query Methods + // ----------------------------------------------------------------------- + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_name: &str) -> bool { + self.load_runtime() + .registry + .has_hooks_for(&HookType::new(hook_name)) + } + + /// Look up a plugin by name. Returns an `Arc` clone โ€” works + /// with the snapshot-based dispatch model where the registry sits + /// behind a transient `Arc` guard. `Arc` + /// derefs to `PluginRef`, so callers can chain methods directly: + /// `mgr.get_plugin("name").unwrap().is_disabled()` still compiles. + pub fn get_plugin(&self, name: &str) -> Option> { + self.load_runtime().registry.get(name) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.load_runtime().registry.plugin_count() + } + + /// All registered plugin names (owned, not borrowed from the registry). + pub fn plugin_names(&self) -> Vec { + self.load_runtime().registry.plugin_names() + } + + /// Whether the manager has been initialized. + pub fn is_initialized(&self) -> bool { + self.initialized.load(Ordering::Acquire) + } + + /// Unregister a plugin by name. + pub fn unregister(&self, name: &str) -> Option> { + let removed = self.mutate_runtime(|snap| snap.registry.unregister(name)); + if removed.is_some() { + self.clear_routing_cache(); + } + removed + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new(ManagerConfig::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::PluginContext; + use crate::error::PluginViolation; + use crate::hooks::payload::Extensions; + use crate::hooks::{HookHandler, PluginResult}; + use crate::plugin::{OnError, PluginMode}; + use async_trait::async_trait; + + // -- Test payload -- + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + // -- Test hook type -- + + struct TestHook; + impl HookTypeDef for TestHook { + type Payload = TestPayload; + type Result = PluginResult; + const NAME: &'static str = "test_hook"; + } + + // -- Test plugins: implement Plugin + HookHandler -- + // No AnyHookHandler boilerplate โ€” the framework handles it. + + /// Plugin that allows everything. + struct AllowPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } + } + + impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + /// Plugin that denies everything. + struct DenyPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } + } + + impl HookHandler for DenyPlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new("denied", "test denial")) + } + } + + /// Handler that always returns an error (for testing on_error behavior). + struct ErrorHandler; + + #[async_trait] + impl AnyHookHandler for ErrorHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + Err(Box::new(PluginError::Execution { + plugin_name: "error-plugin".into(), + message: "simulated failure".into(), + source: None, + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, + })) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Helpers -- + + fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig { + make_config_with_on_error(name, priority, mode, OnError::Fail) + } + + fn make_config_with_on_error( + name: &str, + priority: i32, + mode: PluginMode, + on_error: OnError, + ) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec!["test_hook".to_string()], + mode, + priority, + on_error, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } + } + + fn make_config_with_conditions( + name: &str, + conditions: Vec, + ) -> PluginConfig { + let mut cfg = make_config(name, 10, PluginMode::Sequential); + cfg.conditions = conditions; + cfg + } + + // -- Tests -- + + #[tokio::test] + async fn test_manager_lifecycle() { + let mgr = PluginManager::default(); + assert!(!mgr.is_initialized()); + assert_eq!(mgr.plugin_count(), 0); + + mgr.initialize().await.unwrap(); + assert!(mgr.is_initialized()); + + // Idempotent + mgr.initialize().await.unwrap(); + + mgr.shutdown().await; + assert!(!mgr.is_initialized()); + } + + #[tokio::test] + async fn test_invoke_by_name_no_plugins() { + let mgr = PluginManager::default(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); + } + + #[tokio::test] + async fn test_invoke_by_name_allow() { + let mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + + // Clean registration โ€” no AnyHookHandler needed + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_invoke_by_name_deny() { + let mgr = PluginManager::default(); + let config = make_config("deny-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_invoke_typed() { + let mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "typed".into(), + }; + + let (result, _) = mgr + .invoke::(payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_invoke_named() { + // invoke_named::(hook_name, ...) gives compile-time payload + // type checking while routing to a specific hook name. + let mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "named".into(), + }; + + // TestHook::NAME is "test_hook" โ€” invoke_named routes by the + // explicit hook_name parameter, not H::NAME + let (result, _) = mgr + .invoke_named::("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_invoke_named_no_plugins_for_hook() { + // invoke_named with a hook name that has no registered plugins + let mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "no-match".into(), + }; + + // Plugin is registered under "test_hook", but we invoke "other_hook" + let (result, _) = mgr + .invoke_named::("other_hook", payload, Extensions::default(), None) + .await; + + // No plugins fire โ€” allowed by default + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_invoke_named_deny() { + let mgr = PluginManager::default(); + let config = make_config("deny-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "denied".into(), + }; + + let (result, _) = mgr + .invoke_named::("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_has_hooks_for() { + let mgr = PluginManager::default(); + assert!(!mgr.has_hooks_for("test_hook")); + + let config = make_config("p1", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + mgr.register_handler::(plugin, config).unwrap(); + + assert!(mgr.has_hooks_for("test_hook")); + assert!(!mgr.has_hooks_for("other_hook")); + } + + /// When `routing_enabled` is `false` (the legacy / default mode), + /// each plugin's `conditions:` must be evaluated per request โ€” a + /// non-matching condition should keep the plugin from firing. + /// Mirrors the Python implementation's per-plugin filtering. + #[tokio::test] + async fn test_conditions_filter_plugins_when_routing_disabled() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + let counts: StdArc<[AtomicUsize; 2]> = + StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]); + + struct CountingHandler { + idx: usize, + counts: StdArc<[AtomicUsize; 2]>, + } + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.counts[self.idx].fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + // Plugin A: condition requires tool == "wanted_tool" โ€” fires for matching requests. + let mut tools = std::collections::HashSet::new(); + tools.insert("wanted_tool".to_string()); + let cfg_a = make_config_with_conditions( + "plugin_a", + vec![crate::plugin::PluginCondition { + tools: Some(tools), + ..Default::default() + }], + ); + let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() }); + let handler_a: Arc = Arc::new(CountingHandler { + idx: 0, + counts: StdArc::clone(&counts), + }); + mgr.register_raw::(plugin_a, cfg_a, handler_a) + .unwrap(); + + // Plugin B: empty conditions โ€” fires unconditionally. + let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential); + let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() }); + let handler_b: Arc = Arc::new(CountingHandler { + idx: 1, + counts: StdArc::clone(&counts), + }); + mgr.register_raw::(plugin_b, cfg_b, handler_b) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // Request 1: tool=wanted_tool โ†’ both A and B should fire. + let ext_match = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("wanted_tool".into()), + ..Default::default() + })), + ..Default::default() + }; + let p: Box = Box::new(TestPayload { value: "1".into() }); + let _ = mgr.invoke_by_name("test_hook", p, ext_match, None).await; + assert_eq!( + counts[0].load(Ordering::SeqCst), + 1, + "plugin_a should fire on matching tool" + ); + assert_eq!( + counts[1].load(Ordering::SeqCst), + 1, + "plugin_b should fire (no conditions)" + ); + + // Request 2: tool=other_tool โ†’ only B fires (A's condition rejects). + let ext_no_match = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("other_tool".into()), + ..Default::default() + })), + ..Default::default() + }; + let p: Box = Box::new(TestPayload { value: "2".into() }); + let _ = mgr.invoke_by_name("test_hook", p, ext_no_match, None).await; + assert_eq!( + counts[0].load(Ordering::SeqCst), + 1, + "plugin_a should NOT fire on non-matching tool" + ); + assert_eq!( + counts[1].load(Ordering::SeqCst), + 2, + "plugin_b should fire on every request" + ); + } + + /// `user_patterns` glob matches against `extensions.security.subject.id`. + /// Specifically: pattern `admin-*` matches `admin-alice` but not `user-bob`. + #[tokio::test] + async fn test_conditions_user_patterns_glob_filters() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static FIRED: AtomicUsize = AtomicUsize::new(0); + FIRED.store(0, Ordering::SeqCst); + + struct CountHandler; + #[async_trait] + impl AnyHookHandler for CountHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + FIRED.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + let cfg = make_config_with_conditions( + "admin_only", + vec![crate::plugin::PluginCondition { + user_patterns: Some(vec!["admin-*".to_string()]), + ..Default::default() + }], + ); + let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(CountHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + mgr.initialize().await.unwrap(); + + let ext_with_user = |id: &str| Extensions { + security: Some(std::sync::Arc::new(crate::extensions::SecurityExtension { + subject: Some(crate::extensions::security::SubjectExtension { + id: Some(id.to_string()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + let p: Box = Box::new(TestPayload { value: "1".into() }); + let _ = mgr + .invoke_by_name("test_hook", p, ext_with_user("admin-alice"), None) + .await; + assert_eq!( + FIRED.load(Ordering::SeqCst), + 1, + "admin-alice should match admin-*" + ); + + let p: Box = Box::new(TestPayload { value: "2".into() }); + let _ = mgr + .invoke_by_name("test_hook", p, ext_with_user("user-bob"), None) + .await; + assert_eq!( + FIRED.load(Ordering::SeqCst), + 1, + "user-bob should NOT match admin-*" + ); + } + + #[tokio::test] + async fn test_unregister() { + let mgr = PluginManager::default(); + let config = make_config("removable", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + mgr.register_handler::(plugin, config).unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + mgr.unregister("removable"); + assert_eq!(mgr.plugin_count(), 0); + assert!(!mgr.has_hooks_for("test_hook")); + } + + /// Wraps the manager in `Arc` and dispatches concurrently from many + /// tasks. Also issues a `register_handler` call mid-flight to prove + /// that runtime registration is safe alongside invocations โ€” the whole + /// point of the `ArcSwap`-based snapshot redesign. Before this fix, + /// `register_*` was `&mut self`, so this pattern wouldn't even compile. + #[tokio::test] + async fn test_manager_arc_shareable_with_concurrent_dispatch_and_registration() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static INVOKE_COUNT: AtomicUsize = AtomicUsize::new(0); + INVOKE_COUNT.store(0, Ordering::SeqCst); + + struct CountingHandler; + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + INVOKE_COUNT.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = Arc::new(PluginManager::default()); + + // Register an initial plugin and initialize. + let cfg = make_config("p0", 10, PluginMode::Sequential); + let plugin: Arc = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(CountingHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + mgr.initialize().await.unwrap(); + + // Spawn N concurrent invokers; midway, register a second plugin + // from a different task โ€” the snapshot swaps under their feet. + let n = 16; + let mut handles = Vec::with_capacity(n + 1); + for i in 0..n { + let mgr = Arc::clone(&mgr); + handles.push(tokio::spawn(async move { + let payload: Box = Box::new(TestPayload { + value: format!("call-{}", i), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + })); + } + + // Concurrent registration โ€” proves register_handler works through &Arc. + { + let mgr = Arc::clone(&mgr); + handles.push(tokio::spawn(async move { + let cfg = make_config("p1-late", 20, PluginMode::Sequential); + let plugin: Arc = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(CountingHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + })); + } + + for h in handles { + h.await.unwrap(); + } + + // At least the initial plugin ran for every invoke (some invokes + // may have raced past the registration and only seen the initial + // plugin; others may have seen both). The exact count depends on + // the race, but lower bound is `n` (one fire per invoke for p0). + assert!(INVOKE_COUNT.load(Ordering::SeqCst) >= n); + // Late registration is now visible. + assert_eq!(mgr.plugin_count(), 2); + } + + #[tokio::test] + async fn test_audit_plugin_cannot_block() { + let mgr = PluginManager::default(); + let config = make_config("audit-denier", 10, PluginMode::Audit); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Audit mode โ€” deny is suppressed, pipeline continues + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { + let mgr = PluginManager::default(); + + // Register an error handler with on_error: Disable + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + // Also register a normal allow plugin (lower priority = runs second) + let config2 = make_config("allow-plugin", 20, PluginMode::Sequential); + let plugin2 = Arc::new(AllowPlugin { + cfg: config2.clone(), + }); + mgr.register_handler::(plugin2, config2) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // First invocation โ€” flaky plugin errors, gets disabled, pipeline continues + // because on_error is Disable (not Fail). allow-plugin still runs. + let payload: Box = Box::new(TestPayload { + value: "first".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + // Verify the plugin is now disabled + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Disabled); + + // Second invocation โ€” flaky plugin should be skipped entirely + // (group_by_mode filters it out). Only allow-plugin runs. + let payload2: Box = Box::new(TestPayload { + value: "second".into(), + }); + let (result2, _) = mgr + .invoke_by_name("test_hook", payload2, Extensions::default(), None) + .await; + assert!(result2.continue_processing); + } + + #[tokio::test] + async fn test_on_error_ignore_continues_without_disabling() { + let mgr = PluginManager::default(); + + // Register an error handler with on_error: Ignore + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // First invocation โ€” plugin errors, ignored, pipeline continues + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + // Plugin should NOT be disabled โ€” still in its original mode + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(!plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Sequential); + } + + /// Errors from `on_error: ignore` plugins must surface in + /// `PipelineResult.errors` so callers can see swallowed failures + /// programmatically โ€” not just in log output. + #[tokio::test] + async fn test_on_error_ignore_records_in_pipeline_errors() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Pipeline continued (Ignore policy)โ€ฆ + assert!(result.continue_processing); + // โ€ฆbut the swallowed error is in result.errors with structured fields. + assert_eq!(result.errors.len(), 1, "expected one error record"); + let rec = &result.errors[0]; + assert_eq!(rec.plugin_name, "error-plugin"); + assert!( + rec.message.contains("simulated failure"), + "message lost: {}", + rec.message, + ); + } + + /// Errors from `on_error: disable` plugins must ALSO appear in + /// `PipelineResult.errors` (not just trip the circuit breaker). + #[tokio::test] + async fn test_on_error_disable_records_in_pipeline_errors() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert_eq!(result.errors.len(), 1); + // Plugin was also disabled (the Disable policy's other effect). + assert!(mgr.get_plugin("flaky-plugin").unwrap().is_disabled()); + } + + #[tokio::test] + async fn test_on_error_fail_halts_pipeline() { + let mgr = PluginManager::default(); + + // Register an error handler with on_error: Fail (default) + let config = + make_config_with_on_error("strict-plugin", 10, PluginMode::Sequential, OnError::Fail); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // Invocation โ€” plugin errors, pipeline halts with a violation + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error"); + assert_eq!( + result.violation.as_ref().unwrap().plugin_name.as_deref(), + Some("strict-plugin"), + ); + } + + // -- Additional test plugins -- + + /// Plugin that modifies the payload (for Transform mode testing). + struct TransformPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for TransformPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } + } + + impl HookHandler for TransformPlugin { + async fn handle( + &self, + payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::modify_payload(TestPayload { + value: format!("{}_transformed", payload.value), + }) + } + } + + /// Handler that sleeps (for timeout and fire-and-forget testing). + struct SlowHandler { + delay_ms: u64, + } + + #[async_trait] + impl AnyHookHandler for SlowHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Bug-covering tests -- + + #[tokio::test] + async fn test_transform_modifies_payload() { + let mgr = PluginManager::default(); + let config = make_config("transformer", 10, PluginMode::Transform); + let plugin = Arc::new(TransformPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "original".into(), + }; + + let (result, _) = mgr + .invoke::(payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + let final_payload = result.modified_payload.unwrap(); + let typed = final_payload + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(typed.value, "original_transformed"); + } + + /// Transform phase is documented `can_block: No` (plugin.rs PluginMode + /// table). An `on_error: Fail` plugin error or timeout in Transform must + /// NOT halt the pipeline โ€” non-blocking is non-blocking, regardless of + /// the plugin's stated on_error preference. Disable still works. + #[tokio::test] + async fn test_transform_on_error_fail_does_not_halt_pipeline() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-transform", 10, PluginMode::Transform, OnError::Fail); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!( + result.continue_processing, + "Transform on_error:Fail must not halt the pipeline (phase is non-blocking)", + ); + assert!(result.violation.is_none()); + } + + /// Audit phase previously ignored `on_error` entirely, so an + /// `on_error: Disable` plugin would error forever without the circuit + /// breaker tripping. After the fix Audit honors Disable. + #[tokio::test] + async fn test_audit_on_error_disable_disables_plugin() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-audit", 10, PluginMode::Audit, OnError::Disable); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + assert!(!mgr.get_plugin("flaky-audit").unwrap().is_disabled()); + + // Invoke once โ€” handler errors, on_error=Disable, plugin must be + // disabled. Pipeline still returns success (Audit can't block). + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + assert!( + mgr.get_plugin("flaky-audit").unwrap().is_disabled(), + "Audit phase must honor on_error:Disable", + ); + } + + #[tokio::test] + async fn test_concurrent_multiple_plugins_all_run() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Shared counter to prove both plugins actually ran + static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); + CALL_COUNT.store(0, Ordering::SeqCst); + + struct CountingHandler; + + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + // Small sleep to ensure both tasks are spawned before either finishes + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(CountingHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(CountingHandler); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + let elapsed = start.elapsed(); + + assert!(result.continue_processing); + assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); + // If they ran in parallel, total time should be ~50ms, not ~100ms + assert!( + elapsed.as_millis() < 90, + "concurrent plugins ran serially: {}ms", + elapsed.as_millis() + ); + } + + /// A deny on one concurrent plugin should short-circuit the pipeline + /// AND cancel the slow plugin still running in another task. Previously + /// `join_all` waited for every task before noticing the deny, so + /// short_circuit_on_deny was a no-op in wall-clock terms and the slow + /// plugin completed its side effects after the pipeline returned. + #[tokio::test] + async fn test_concurrent_short_circuit_aborts_slow_plugin() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0); + SLOW_COMPLETED.store(0, Ordering::SeqCst); + + struct DenyImmediately; + #[async_trait] + impl AnyHookHandler for DenyImmediately { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let result: PluginResult = + PluginResult::deny(PluginViolation::new("denied", "fast deny")); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + struct SlowSideEffect; + #[async_trait] + impl AnyHookHandler for SlowSideEffect { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + tokio::time::sleep(Duration::from_secs(2)).await; + // If the task isn't aborted at the sleep's await point, + // this fetch_add fires after the pipeline already returned. + SLOW_COMPLETED.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let cfg_deny = make_config("denier", 10, PluginMode::Concurrent); + let plugin_deny = Arc::new(AllowPlugin { + cfg: cfg_deny.clone(), + }); + mgr.register_raw::( + plugin_deny, + cfg_deny, + Arc::new(DenyImmediately) as Arc, + ) + .unwrap(); + + let cfg_slow = make_config("slow", 20, PluginMode::Concurrent); + let plugin_slow = Arc::new(AllowPlugin { + cfg: cfg_slow.clone(), + }); + mgr.register_raw::( + plugin_slow, + cfg_slow, + Arc::new(SlowSideEffect) as Arc, + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // Pipeline must return quickly โ€” the deny short-circuits before + // the 2s sleep completes. + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + let elapsed = start.elapsed(); + + assert!(!result.continue_processing); + assert!( + elapsed < Duration::from_millis(500), + "pipeline should short-circuit on deny, but took {}ms (slow plugin not aborted)", + elapsed.as_millis(), + ); + + // Wait long enough that the slow plugin's sleep would have finished + // if it hadn't been aborted, then verify its side effect didn't fire. + tokio::time::sleep(Duration::from_millis(2_500)).await; + assert_eq!( + SLOW_COMPLETED.load(Ordering::SeqCst), + 0, + "slow plugin's side effect ran after pipeline returned โ€” task was not aborted", + ); + } + + /// short_circuit_on_deny=false: every concurrent plugin must run to + /// completion (no abort), and the earliest deny is returned at the end. + #[tokio::test] + async fn test_concurrent_no_short_circuit_runs_every_plugin() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ALLOW_RAN: AtomicUsize = AtomicUsize::new(0); + ALLOW_RAN.store(0, Ordering::SeqCst); + + struct DenyImmediately; + #[async_trait] + impl AnyHookHandler for DenyImmediately { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let result: PluginResult = + PluginResult::deny(PluginViolation::new("denied", "fast deny")); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + struct AllowAndCount; + #[async_trait] + impl AnyHookHandler for AllowAndCount { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + ALLOW_RAN.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 30, + short_circuit_on_deny: false, + }, + route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, + }; + let mgr = PluginManager::new(config); + + let cfg_deny = make_config("denier", 10, PluginMode::Concurrent); + let plugin_deny = Arc::new(AllowPlugin { + cfg: cfg_deny.clone(), + }); + mgr.register_raw::( + plugin_deny, + cfg_deny, + Arc::new(DenyImmediately) as Arc, + ) + .unwrap(); + + let cfg_allow = make_config("allow", 20, PluginMode::Concurrent); + let plugin_allow = Arc::new(AllowPlugin { + cfg: cfg_allow.clone(), + }); + mgr.register_raw::( + plugin_allow, + cfg_allow, + Arc::new(AllowAndCount) as Arc, + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Earliest deny is returnedโ€ฆ + assert!(!result.continue_processing); + // โ€ฆbut the non-denying plugin must still have run (no abort). + assert_eq!(ALLOW_RAN.load(Ordering::SeqCst), 1); + } + + /// Plugin handler that panics inside its async invoke. With tokio::spawn, + /// the panic surfaces as a JoinError on the task's JoinHandle. + struct PanicHandler; + + #[async_trait] + impl AnyHookHandler for PanicHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + panic!("simulated panic in concurrent plugin task"); + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + /// A panicking concurrent plugin with `on_error: Fail` must halt the + /// pipeline with a violation. Previously the JoinError was just logged + /// and the panic was silently swallowed. + /// + /// Note: this test prints "thread 'tokio-runtime-worker' panicked at..." + /// to stderr โ€” that's tokio reporting the captured panic. Expected. + #[tokio::test] + async fn test_concurrent_panic_with_on_error_fail_halts_pipeline() { + let mgr = PluginManager::default(); + + let cfg = + make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Fail); + let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(PanicHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!( + !result.continue_processing, + "Fail must halt the pipeline on panic" + ); + let v = result.violation.as_ref().expect("expected violation"); + assert_eq!(v.code, "plugin_panic"); + assert_eq!(v.plugin_name.as_deref(), Some("panic-plugin")); + } + + /// A panicking concurrent plugin with `on_error: Disable` must trip + /// the plugin's circuit breaker so it's skipped on subsequent invokes. + /// A second non-panicking plugin in the same phase still runs. + #[tokio::test] + async fn test_concurrent_panic_with_on_error_disable_trips_circuit_breaker() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SURVIVOR_CALLS: AtomicUsize = AtomicUsize::new(0); + SURVIVOR_CALLS.store(0, Ordering::SeqCst); + + struct SurvivorHandler; + #[async_trait] + impl AnyHookHandler for SurvivorHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + SURVIVOR_CALLS.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let panic_cfg = + make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Disable); + let panic_plugin = Arc::new(AllowPlugin { + cfg: panic_cfg.clone(), + }); + let panic_handler: Arc = Arc::new(PanicHandler); + mgr.register_raw::(panic_plugin, panic_cfg, panic_handler) + .unwrap(); + + let survivor_cfg = make_config("survivor", 20, PluginMode::Concurrent); + let survivor_plugin = Arc::new(AllowPlugin { + cfg: survivor_cfg.clone(), + }); + let survivor_handler: Arc = Arc::new(SurvivorHandler); + mgr.register_raw::(survivor_plugin, survivor_cfg, survivor_handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // First invoke โ€” panic plugin panics, gets disabled. Survivor still runs. + let payload: Box = Box::new(TestPayload { value: "1".into() }); + let (result1, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!( + result1.continue_processing, + "Disable must not halt the pipeline" + ); + assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 1); + assert!( + mgr.get_plugin("panic-plugin").unwrap().is_disabled(), + "panic plugin must be disabled after the panic", + ); + + // Second invoke โ€” disabled plugin is skipped, doesn't panic again. + let payload2: Box = Box::new(TestPayload { value: "2".into() }); + let (result2, _) = mgr + .invoke_by_name("test_hook", payload2, Extensions::default(), None) + .await; + assert!(result2.continue_processing); + // Survivor ran a second time; panic plugin did not. + assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn test_timeout_fires_on_slow_handler() { + // Create a manager with a very short timeout + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 1, + short_circuit_on_deny: true, + }, + route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, + }; + let mgr = PluginManager::new(config); + + // Register a handler that sleeps longer than the timeout + let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: plugin_config.clone(), + }); + let handler: Arc = Arc::new(SlowHandler { delay_ms: 5000 }); + mgr.register_raw::(plugin, plugin_config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + let elapsed = start.elapsed(); + + // Should have timed out and denied (on_error: Fail) + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); + // Should have returned in ~1s, not 5s + assert!( + elapsed.as_secs() < 3, + "timeout didn't fire: {}s", + elapsed.as_secs() + ); + } + + #[tokio::test] + async fn test_fire_and_forget_returns_before_task_completes() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); + TASK_COMPLETED.store(false, Ordering::SeqCst); + + struct SlowFireAndForgetHandler; + + #[async_trait] + impl AnyHookHandler for SlowFireAndForgetHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + TASK_COMPLETED.store(true, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let config = make_config("fire-forget", 10, PluginMode::FireAndForget); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(SlowFireAndForgetHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, bg) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Pipeline should return immediately โ€” before the background task finishes + assert!(result.continue_processing); + assert!( + !TASK_COMPLETED.load(Ordering::SeqCst), + "fire-and-forget task completed before pipeline returned" + ); + + // Wait for background tasks using wait_for_background_tasks() + let errors = bg.wait_for_background_tasks().await; + assert!( + errors.is_empty(), + "background task had errors: {:?}", + errors + ); + assert!( + TASK_COMPLETED.load(Ordering::SeqCst), + "fire-and-forget task never completed" + ); + } + + /// `shutdown()` must wait for in-flight fire-and-forget tasks to drain + /// before returning, so audit / telemetry plugins that flush at the + /// end of a request lifetime aren't cancelled mid-write. The caller + /// drops `BackgroundTasks` (the common case for fire-and-forget), + /// so the only way the manager knows about the in-flight task is the + /// internal `TaskTracker`. + #[tokio::test] + async fn test_shutdown_drains_in_flight_fire_and_forget_tasks() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static FAF_COMPLETED: AtomicBool = AtomicBool::new(false); + FAF_COMPLETED.store(false, Ordering::SeqCst); + + struct SlowFafHandler; + #[async_trait] + impl AnyHookHandler for SlowFafHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + FAF_COMPLETED.store(true, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + let config = make_config("slow-faf", 10, PluginMode::FireAndForget); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(SlowFafHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + mgr.initialize().await.unwrap(); + + // Invoke and drop BackgroundTasks immediately โ€” simulating the + // common case where the caller doesn't explicitly wait for FAF. + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (_result, _bg_dropped) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Task should still be in flight (sleeping 150ms). + assert!(!FAF_COMPLETED.load(Ordering::SeqCst)); + + // shutdown() must drain in-flight FAF tasks before returning. + mgr.shutdown().await; + + // After shutdown, the FAF task must have run to completion. + assert!( + FAF_COMPLETED.load(Ordering::SeqCst), + "shutdown returned before fire-and-forget task finished โ€” task was abandoned", + ); + } + + #[tokio::test] + async fn test_global_state_flows_between_serial_plugins() { + // Plugin A writes to global_state; Plugin B reads it. + + struct WriterHandler; + + #[async_trait] + impl AnyHookHandler for WriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + struct ReaderHandler { + saw_writer: std::sync::Arc, + } + + #[async_trait] + impl AnyHookHandler for ReaderHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + if ctx.get_global("writer_was_here").is_some() { + self.saw_writer + .store(true, std::sync::atomic::Ordering::SeqCst); + } + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let mgr = PluginManager::default(); + + // Writer runs first (priority 10) + let c1 = make_config("writer", 10, PluginMode::Sequential); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(WriterHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + // Reader runs second (priority 20) + let c2 = make_config("reader", 20, PluginMode::Sequential); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(ReaderHandler { + saw_writer: saw_writer.clone(), + }); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert!( + saw_writer.load(std::sync::atomic::Ordering::SeqCst), + "reader plugin did not see writer's global_state change" + ); + } + + #[tokio::test] + async fn test_local_state_persists_across_hook_invocations() { + // Plugin writes to local_state on first hook call. + // Context table is threaded into second call โ€” local_state preserved. + + struct LocalWriterHandler; + + #[async_trait] + impl AnyHookHandler for LocalWriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + // Increment a counter in local_state + let count = ctx + .get_local("call_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + ctx.set_local("call_count", serde_json::Value::from(count + 1)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let config = make_config("counter", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(LocalWriterHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // First invocation โ€” no context table, starts fresh + let payload: Box = Box::new(TestPayload { + value: "first".into(), + }); + let (result1, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result1.continue_processing); + + // Check call_count = 1 in the returned context table + let table = &result1.context_table; + let local = table + .local_states + .values() + .next() + .expect("context table should have one local_state entry"); + assert_eq!(local.get("call_count").unwrap().as_u64().unwrap(), 1); + + // Second invocation โ€” pass the context table from the first call + let payload2: Box = Box::new(TestPayload { + value: "second".into(), + }); + let (result2, _) = mgr + .invoke_by_name( + "test_hook", + payload2, + Extensions::default(), + Some(result1.context_table), + ) + .await; + assert!(result2.continue_processing); + + // call_count should now be 2 โ€” local_state persisted across invocations + let table2 = &result2.context_table; + let local2 = table2 + .local_states + .values() + .next() + .expect("context table should have one local_state entry"); + assert_eq!(local2.get("call_count").unwrap().as_u64().unwrap(), 2); + } + + /// global_state writes by an earlier plugin must be visible to a later + /// plugin in the same serial phase, and the canonical state on the + /// returned context_table must reflect every plugin's contribution in + /// priority order. Previously this relied on `ctx_table.values().last()` + /// (HashMap iteration order โ€” non-deterministic). + #[tokio::test] + async fn test_global_state_propagates_in_priority_order() { + /// Handler that appends `tag` to global_state["chain"] (creating + /// an array if absent). After running, the array reveals the + /// observed run order from each plugin's perspective. + struct GlobalChainHandler { + tag: &'static str, + } + + #[async_trait] + impl AnyHookHandler for GlobalChainHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + let mut chain = ctx + .get_global("chain") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + chain.push(serde_json::Value::String(self.tag.into())); + ctx.set_global("chain", serde_json::Value::Array(chain)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + // Plugin A โ€” priority 10 (runs first) + let cfg_a = make_config("plugin_a", 10, PluginMode::Sequential); + let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() }); + let handler_a: Arc = Arc::new(GlobalChainHandler { tag: "a" }); + mgr.register_raw::(plugin_a, cfg_a, handler_a) + .unwrap(); + + // Plugin B โ€” priority 20 (runs second) + let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential); + let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() }); + let handler_b: Arc = Arc::new(GlobalChainHandler { tag: "b" }); + mgr.register_raw::(plugin_b, cfg_b, handler_b) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + // Canonical global_state on the returned table must contain both + // contributions in priority order โ€” proving plugin B observed plugin + // A's write, and the table holds the merged result, not an arbitrary + // plugin's snapshot. + let chain = result + .context_table + .global_state + .get("chain") + .and_then(|v| v.as_array()) + .expect("global_state.chain should be an array"); + let tags: Vec<&str> = chain.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(tags, vec!["a", "b"]); + } + + /// All five phases (Sequential, Transform, Audit, Concurrent, + /// FireAndForget) execute in the documented order, with payload + /// modifications from earlier phases visible in later ones. Closes + /// the review's "no multi-phase combination test" gap. + #[tokio::test] + async fn test_all_five_phases_run_in_order_with_payload_chaining() { + use std::sync::Arc as StdArc; + use std::sync::Mutex as StdMutex; + + let log: StdArc>> = StdArc::new(StdMutex::new(Vec::new())); + + // Sequential โ€” modifies payload, logs "seq". + struct SeqHandler { + log: StdArc>>, + } + #[async_trait] + impl AnyHookHandler for SeqHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.log.lock().unwrap().push("seq"); + let typed = payload.as_any().downcast_ref::().unwrap(); + let modified = TestPayload { + value: format!("{}|seq", typed.value), + }; + let result: PluginResult = PluginResult::modify_payload(modified); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // Transform โ€” modifies payload, logs "transform". + struct TransformLogger { + log: StdArc>>, + } + #[async_trait] + impl AnyHookHandler for TransformLogger { + async fn invoke( + &self, + payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.log.lock().unwrap().push("transform"); + let typed = payload.as_any().downcast_ref::().unwrap(); + let modified = TestPayload { + value: format!("{}|transform", typed.value), + }; + let result: PluginResult = PluginResult::modify_payload(modified); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // Logger that asserts the payload it observes contains both prior + // phases' marks (proving payload chaining made it this far). + struct ObserverHandler { + tag: &'static str, + log: StdArc>>, + expected_payload: &'static str, + } + #[async_trait] + impl AnyHookHandler for ObserverHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let typed = payload.as_any().downcast_ref::().unwrap(); + assert_eq!( + typed.value, self.expected_payload, + "{} observed unexpected payload: got '{}', expected '{}'", + self.tag, typed.value, self.expected_payload, + ); + self.log.lock().unwrap().push(self.tag); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let cfg_seq = make_config("seq", 10, PluginMode::Sequential); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_seq.clone(), + }), + cfg_seq, + Arc::new(SeqHandler { + log: StdArc::clone(&log), + }), + ) + .unwrap(); + + let cfg_transform = make_config("transform", 10, PluginMode::Transform); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_transform.clone(), + }), + cfg_transform, + Arc::new(TransformLogger { + log: StdArc::clone(&log), + }), + ) + .unwrap(); + + let cfg_audit = make_config("audit", 10, PluginMode::Audit); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_audit.clone(), + }), + cfg_audit, + Arc::new(ObserverHandler { + tag: "audit", + log: StdArc::clone(&log), + expected_payload: "start|seq|transform", + }), + ) + .unwrap(); + + let cfg_concurrent = make_config("concurrent", 10, PluginMode::Concurrent); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_concurrent.clone(), + }), + cfg_concurrent, + Arc::new(ObserverHandler { + tag: "concurrent", + log: StdArc::clone(&log), + expected_payload: "start|seq|transform", + }), + ) + .unwrap(); + + let cfg_faf = make_config("faf", 10, PluginMode::FireAndForget); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_faf.clone(), + }), + cfg_faf, + Arc::new(ObserverHandler { + tag: "faf", + log: StdArc::clone(&log), + expected_payload: "start|seq|transform", + }), + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "start".into(), + }); + let (result, bg) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + // Final payload should have both modify-phase marks. + let final_payload = result.modified_payload.unwrap(); + let typed = final_payload + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(typed.value, "start|seq|transform"); + + // Drain the FAF task before checking ordering โ€” its log entry + // races the rest of the function otherwise. + let _ = bg.wait_for_background_tasks().await; + + let log = log.lock().unwrap(); + // Sequential, Transform, Audit are guaranteed in order (serial phases). + assert_eq!(log[0], "seq", "first should be sequential phase"); + assert_eq!(log[1], "transform", "second should be transform phase"); + assert_eq!(log[2], "audit", "third should be audit phase"); + // Concurrent runs before invoke returns; FAF was waited on above. + // Their relative order with each other is not strictly guaranteed + // (FAF spawns *after* concurrent finishes, but tokio scheduling + // can interleave). Just check both present in indices 3 / 4. + let post_audit: std::collections::HashSet<&&'static str> = log[3..].iter().collect(); + assert!( + post_audit.contains(&"concurrent"), + "concurrent phase must run" + ); + assert!(post_audit.contains(&"faf"), "fire-and-forget must run"); + assert_eq!(log.len(), 5, "all five phases should have logged"); + } + + /// Routing must work for `resource`, `prompt`, and `llm` entity types + /// โ€” not just `tool`. Closes the review's "no test verifying entity + /// types other than tool in routing" gap. + #[tokio::test] + async fn test_routing_works_for_all_entity_types() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + // One counter per entity-type test; each plugin only fires when + // the route resolves to it. + struct CountHandler { + counter: StdArc, + } + #[async_trait] + impl AnyHookHandler for CountHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.counter.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // Each row: (entity_type, route field name, route value, request entity_name, should_match) + // We build a fresh manager per entity type so routes don't bleed. + for (entity_type, route_field, route_value, request_name, should_match) in [ + ("resource", "resource", "my_resource", "my_resource", true), + ( + "resource", + "resource", + "my_resource", + "other_resource", + false, + ), + ("prompt", "prompt", "my_prompt", "my_prompt", true), + ("prompt", "prompt", "my_prompt", "other_prompt", false), + ("llm", "llm", "gpt-4", "gpt-4", true), + ("llm", "llm", "gpt-4", "claude", false), + ] { + let yaml = format!( + r#" +plugin_settings: + routing_enabled: true +plugins: + - name: target + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - {route_field}: {route_value} + plugins: + - target +"# + ); + let cpex_config = crate::config::parse_config(&yaml).unwrap(); + + let mgr = PluginManager::default(); + let counter = StdArc::new(AtomicUsize::new(0)); + // Custom factory that hands out a CountHandler with our shared counter. + struct ParamFactory(StdArc); + impl crate::factory::PluginFactory for ParamFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + Ok(crate::factory::PluginInstance { + plugin: Arc::new(AllowPlugin { + cfg: config.clone(), + }), + handlers: vec![( + "test_hook", + Arc::new(CountHandler { + counter: StdArc::clone(&self.0), + }), + )], + }) + } + } + mgr.register_factory( + "test/allow", + Box::new(ParamFactory(StdArc::clone(&counter))), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + let p: Box = Box::new(TestPayload { value: "x".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some(entity_type.into()), + entity_name: Some(request_name.into()), + ..Default::default() + })), + ..Default::default() + }; + let _ = mgr.invoke_by_name("test_hook", p, ext, None).await; + + let expected = if should_match { 1 } else { 0 }; + assert_eq!( + counter.load(Ordering::SeqCst), + expected, + "entity_type={} route_field={} route_value={} request_name={} expected fire={}", + entity_type, + route_field, + route_value, + request_name, + should_match, + ); + } + } + + /// `initialize()` must roll back already-initialized plugins by + /// calling `shutdown()` on each, in reverse order, when a later + /// plugin's `initialize()` fails. Closes the review's "no test for + /// initialize() rollback path" gap. + #[tokio::test] + async fn test_initialize_rollback_on_failure() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + // Track per-plugin init / shutdown invocations. + let init_count_a = StdArc::new(AtomicUsize::new(0)); + let shutdown_count_a = StdArc::new(AtomicUsize::new(0)); + let init_count_b = StdArc::new(AtomicUsize::new(0)); + let shutdown_count_b = StdArc::new(AtomicUsize::new(0)); + let init_count_c = StdArc::new(AtomicUsize::new(0)); + let shutdown_count_c = StdArc::new(AtomicUsize::new(0)); + + struct LifecyclePlugin { + cfg: PluginConfig, + init_counter: StdArc, + shutdown_counter: StdArc, + fail_init: bool, + } + #[async_trait] + impl Plugin for LifecyclePlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + self.init_counter.fetch_add(1, Ordering::SeqCst); + if self.fail_init { + Err(Box::new(PluginError::Config { + message: "intentional init failure".into(), + })) + } else { + Ok(()) + } + } + async fn shutdown(&self) -> Result<(), Box> { + self.shutdown_counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + impl HookHandler for LifecyclePlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + let mgr = PluginManager::default(); + + // Plugin A: initializes successfully (priority 10, registered first). + let cfg_a = make_config("a", 10, PluginMode::Sequential); + let plugin_a = Arc::new(LifecyclePlugin { + cfg: cfg_a.clone(), + init_counter: StdArc::clone(&init_count_a), + shutdown_counter: StdArc::clone(&shutdown_count_a), + fail_init: false, + }); + mgr.register_handler::(plugin_a, cfg_a) + .unwrap(); + + // Plugin B: initialize() returns Err โ€” should trigger rollback. + let cfg_b = make_config("b", 20, PluginMode::Sequential); + let plugin_b = Arc::new(LifecyclePlugin { + cfg: cfg_b.clone(), + init_counter: StdArc::clone(&init_count_b), + shutdown_counter: StdArc::clone(&shutdown_count_b), + fail_init: true, + }); + mgr.register_handler::(plugin_b, cfg_b) + .unwrap(); + + // Plugin C: never reached (init aborts at B). + let cfg_c = make_config("c", 30, PluginMode::Sequential); + let plugin_c = Arc::new(LifecyclePlugin { + cfg: cfg_c.clone(), + init_counter: StdArc::clone(&init_count_c), + shutdown_counter: StdArc::clone(&shutdown_count_c), + fail_init: false, + }); + mgr.register_handler::(plugin_c, cfg_c) + .unwrap(); + + let result = mgr.initialize().await; + assert!( + result.is_err(), + "initialize() must propagate the init failure" + ); + + // The registry iterates plugins in `HashMap` order, which is + // randomized โ€” so we don't know whether A and C were reached + // before B failed. The rollback invariants are order-independent: + // + // - For non-failing plugins (A, C): if init() was called, shutdown() + // must have been called too (rolled back). If init() was not + // called (B happened to iterate first), shutdown() shouldn't + // have either. In both cases, init_count == shutdown_count. + // - B's init() was called and failed, so its shutdown() must NOT + // run โ€” failed-init plugins are not part of the rollback set. + let assert_pair_invariant = + |init: &AtomicUsize, shutdown: &AtomicUsize, tag: &str| { + let i = init.load(Ordering::SeqCst); + let s = shutdown.load(Ordering::SeqCst); + assert!( + (i == 0 && s == 0) || (i == 1 && s == 1), + "{}: init/shutdown should be paired (both 0 or both 1), got init={} shutdown={}", + tag, i, s, + ); + }; + assert_pair_invariant(&init_count_a, &shutdown_count_a, "A"); + assert_pair_invariant(&init_count_c, &shutdown_count_c, "C"); + + // B specifically: init was called and failed; no shutdown for it. + assert_eq!( + init_count_b.load(Ordering::SeqCst), + 1, + "B's initialize was called", + ); + assert_eq!( + shutdown_count_b.load(Ordering::SeqCst), + 0, + "B failed to initialize; shutdown should not run for it", + ); + + // Manager must report not-initialized after the failure. + assert!(!mgr.is_initialized()); + } + + // -- Factory-based tests -- + + /// A test factory that creates AllowPlugin instances. + struct AllowPluginFactory; + + impl crate::factory::PluginFactory for AllowPluginFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&plugin), + )); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + /// A test factory that creates DenyPlugin instances. + struct DenyPluginFactory; + + impl crate::factory::PluginFactory for DenyPluginFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&plugin), + )); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + #[tokio::test] + async fn test_from_config_creates_manager() { + let yaml = r#" +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 + +plugin_settings: + plugin_timeout: 60 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + assert!(mgr.has_hooks_for("test_hook")); + } + + #[tokio::test] + async fn test_from_config_invokes_correctly() { + let yaml = r#" +plugins: + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/deny", Box::new(DenyPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + // context_table = None (first invocation) + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_from_config_unknown_kind_rejected() { + let yaml = r#" +plugins: + - name: mystery + kind: unknown/type + hooks: [test_hook] +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let factories = PluginFactoryRegistry::new(); // empty โ€” no factories + + let result = PluginManager::from_config(cpex_config, &factories); + match result { + Err(e) => assert!( + e.to_string().contains("no factory registered"), + "got: {}", + e + ), + Ok(_) => panic!("expected error for unknown kind"), + } + } + + #[tokio::test] + async fn test_from_config_multiple_plugins() { + let yaml = r#" +plugins: + - name: gate + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 5 + - name: fallback + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + factories.register("test/deny", Box::new(DenyPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 2); + + // Deny plugin has higher priority (5 < 10), so it fires first + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + // context_table = None (first invocation) + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); // gate denied before fallback could allow + } + + // -- Routing cache tests -- + + #[tokio::test] + async fn test_routing_cache_populated_on_first_invoke() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.routing_cache_size(), 0); + + // First invoke โ€” populates cache + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + // context_table = None (first invocation) + mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert_eq!(mgr.routing_cache_size(), 1); + + // Second invoke โ€” cache hit, still size 1 + let payload2: Box = Box::new(TestPayload { + value: "test2".into(), + }); + let ext2 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload2, ext2, None).await; + + assert_eq!(mgr.routing_cache_size(), 1); // cache hit โ€” no new entry + } + + #[tokio::test] + async fn test_routing_cache_different_entities_separate() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation + - tool: send_email +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Invoke for get_compensation + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let e1 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p1, e1, None).await; + + // Invoke for send_email + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let e2 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("send_email".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p2, e2, None).await; + + assert_eq!(mgr.routing_cache_size(), 2); + } + + #[tokio::test] + async fn test_routing_cache_cleared() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload, ext, None).await; + assert_eq!(mgr.routing_cache_size(), 1); + + mgr.clear_routing_cache(); + assert_eq!(mgr.routing_cache_size(), 0); + } + + #[tokio::test] + async fn test_unregister_invalidates_routing_cache() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload, ext, None).await; + assert_eq!(mgr.routing_cache_size(), 1); + + // Unregister should invalidate the cache so removed plugins + // don't continue firing from stale cached entries. + mgr.unregister("allow_plugin"); + assert_eq!(mgr.routing_cache_size(), 0); + } + + #[test] + fn test_routing_cache_recovers_from_poisoned_lock() { + // A panic while holding the cache lock poisons it. Before the fix, + // every subsequent read()/write() would unwrap a PoisonError and + // panic, permanently breaking dispatch. With unwrap_or_else + + // into_inner, the cache stays usable. + // + // Note: this test intentionally panics inside catch_unwind, which + // prints "thread 'manager::tests::...' panicked at..." to test + // output even though the panic is caught. That's expected. + use std::panic::AssertUnwindSafe; + + let mgr = PluginManager::default(); + + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let _guard = mgr.route_cache.write().unwrap(); + panic!("simulated panic while holding cache lock"); + })); + assert!(result.is_err(), "expected the panic to be caught"); + assert!( + mgr.route_cache.is_poisoned(), + "lock should be poisoned after the panic", + ); + + // All four lock sites must now succeed despite the poison flag. + assert_eq!(mgr.routing_cache_size(), 0); + mgr.clear_routing_cache(); + assert_eq!(mgr.routing_cache_size(), 0); + } + + #[tokio::test] + async fn test_routing_cache_rejects_inserts_at_capacity() { + // Cap of 2 โ€” verifies bound holds AND uncached requests still resolve correctly. + let yaml = r#" +plugin_settings: + routing_enabled: true + route_cache_max_entries: 2 +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: a + - tool: b + - tool: c +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + let invoke_for = |entity: &'static str| -> (Box, Extensions) { + let p: Box = Box::new(TestPayload { + value: entity.into(), + }); + let e = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(entity.into()), + ..Default::default() + })), + ..Default::default() + }; + (p, e) + }; + + // Fill to cap (2 distinct entities). + let (p1, e1) = invoke_for("a"); + let (r1, _) = mgr.invoke_by_name("test_hook", p1, e1, None).await; + assert!(r1.continue_processing); + assert_eq!(mgr.routing_cache_size(), 1); + + let (p2, e2) = invoke_for("b"); + let (r2, _) = mgr.invoke_by_name("test_hook", p2, e2, None).await; + assert!(r2.continue_processing); + assert_eq!(mgr.routing_cache_size(), 2); + + // Third entity โ€” cache is full, insert is rejected. + // Pipeline must still run correctly (slow path resolves the route). + let (p3, e3) = invoke_for("c"); + let (r3, _) = mgr.invoke_by_name("test_hook", p3, e3, None).await; + assert!( + r3.continue_processing, + "slow path must still resolve when cache is full" + ); + assert_eq!(mgr.routing_cache_size(), 2, "cache must not exceed cap"); + + // Repeated request for the same uncached entity also works. + let (p4, e4) = invoke_for("c"); + let (r4, _) = mgr.invoke_by_name("test_hook", p4, e4, None).await; + assert!(r4.continue_processing); + assert_eq!(mgr.routing_cache_size(), 2); + + // Clearing the cache lets new entries memoize again. + mgr.clear_routing_cache(); + let (p5, e5) = invoke_for("c"); + mgr.invoke_by_name("test_hook", p5, e5, None).await; + assert_eq!(mgr.routing_cache_size(), 1); + } + + #[tokio::test] + async fn test_register_handler_invalidates_routing_cache() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload, ext, None).await; + assert_eq!(mgr.routing_cache_size(), 1); + + // Registering a new handler must invalidate the cache so the + // new plugin is visible to subsequent route resolutions. + let extra_cfg = make_config("late_plugin", 20, PluginMode::Sequential); + let extra = Arc::new(AllowPlugin { + cfg: extra_cfg.clone(), + }); + mgr.register_handler::(extra, extra_cfg) + .unwrap(); + assert_eq!(mgr.routing_cache_size(), 0); + } + + #[tokio::test] + async fn test_routing_cache_scope_creates_separate_entries() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Same entity, different scopes โ†’ separate cache entries + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let e1 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + scope: Some("hr-server".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p1, e1, None).await; + + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let e2 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + scope: Some("billing-server".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p2, e2, None).await; + + assert_eq!(mgr.routing_cache_size(), 2); // different scopes โ†’ different cache entries + } + + // -- Override instance tests -- + + #[tokio::test] + async fn test_route_override_creates_new_instance() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 + config: + max_requests: 100 +routes: + - tool: get_compensation + plugins: + - rate_limiter: + config: + max_requests: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + // Use register_factory + load_config so manager owns factories + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Invoke with routing โ€” should create override instance + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + // context_table = None (first invocation) + + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; + + // Plugin executed (allow plugin returns allowed) + assert!(result.continue_processing); + // Cache populated + assert_eq!(mgr.routing_cache_size(), 1); + } + + /// Override instances must have `initialize()` called so plugins that + /// open DB connections / file handles / network clients on init don't + /// run with default state. Uses a tracking factory whose plugin + /// increments a counter inside its `initialize()`. + #[tokio::test] + async fn test_route_override_initializes_new_instance() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static INIT_COUNT: AtomicUsize = AtomicUsize::new(0); + INIT_COUNT.store(0, Ordering::SeqCst); + + struct InitTrackingPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for InitTrackingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + INIT_COUNT.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } + } + + impl HookHandler for InitTrackingPlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + struct InitTrackingFactory; + impl crate::factory::PluginFactory for InitTrackingFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + let plugin = Arc::new(InitTrackingPlugin { + cfg: config.clone(), + }); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&plugin), + )); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: tracker + kind: test/init_tracking + hooks: [test_hook] + mode: sequential + priority: 10 + config: + max_requests: 100 +routes: + - tool: get_compensation + plugins: + - tracker: + config: + max_requests: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("test/init_tracking", Box::new(InitTrackingFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Base plugin was initialized exactly once during mgr.initialize(). + assert_eq!(INIT_COUNT.load(Ordering::SeqCst), 1); + + // Invoke with route override โ€” creates a new instance via factory. + // That new instance must also be initialized. + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let (result, _) = mgr + .invoke_by_name( + "test_hook", + payload, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(result.continue_processing); + + assert_eq!( + INIT_COUNT.load(Ordering::SeqCst), + 2, + "override instance must have initialize() called", + ); + } + + /// Override and base must have INDEPENDENT circuit breakers. A failure + /// on an override-only route (e.g., bad credentials in the merged + /// config) must not silently disable the plugin for every other route + /// using the base config โ€” config is part of the failure surface, and + /// per-route blast radius is the point of having overrides. + #[tokio::test] + async fn test_route_override_circuit_breaker_isolated_from_base() { + struct ErrorOnInvokeFactory; + impl crate::factory::PluginFactory for ErrorOnInvokeFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: flaky + kind: test/error_on_invoke + hooks: [test_hook] + mode: sequential + priority: 10 + on_error: disable +routes: + - tool: get_compensation + plugins: + - flaky: + config: + something: changed +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("test/error_on_invoke", Box::new(ErrorOnInvokeFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + assert!( + !mgr.get_plugin("flaky").unwrap().is_disabled(), + "should start enabled" + ); + + // Invoke a route that uses the override. The override's handler + // errors with `on_error: Disable`, so the executor calls disable() + // on the *override's* plugin_ref. Independent circuit breakers + // mean the base must stay enabled. + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let _ = mgr + .invoke_by_name( + "test_hook", + payload, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + + assert!( + !mgr.get_plugin("flaky").unwrap().is_disabled(), + "base must NOT be disabled when an override trips its own circuit breaker", + ); + } + + #[tokio::test] + async fn test_register_factory_then_load_config() { + let yaml = r#" +plugins: + - name: my_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 + +plugin_settings: + plugin_timeout: 45 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + assert!(mgr.has_hooks_for("test_hook")); + + let payload: Box = Box::new(TestPayload { value: "t".into() }); + // context_table = None (first invocation) + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + } + + // -- End-to-end routing tests -- + + /// Helper to build meta extensions for routing tests. + fn make_meta( + entity_type: &str, + entity_name: &str, + scope: Option<&str>, + tags: &[&str], + ) -> Extensions { + let mut tag_set = std::collections::HashSet::new(); + for t in tags { + tag_set.insert(t.to_string()); + } + Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some(entity_type.into()), + entity_name: Some(entity_name.into()), + scope: scope.map(String::from), + tags: tag_set, + ..Default::default() + })), + ..Default::default() + } + } + + #[tokio::test] + async fn test_routing_full_flow_different_tools_different_plugins() { + // Setup: identity fires for all, apl_policy fires for pii tools, + // rate_limiter fires only for get_compensation route + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] + pii: + plugins: [apl_policy] +plugins: + - name: identity + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: apl_policy + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 + - name: rate_limiter + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 5 +routes: + - tool: get_compensation + meta: + tags: [pii] + plugins: + - rate_limiter + - tool: send_email + plugins: + - rate_limiter +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // get_compensation: identity (all) + apl_policy (pii tag) + rate_limiter (route) + // apl_policy denies โ†’ overall denied + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let (r1, _) = mgr + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(!r1.continue_processing); // apl_policy (deny) fires due to pii tag + + // send_email: identity (all) + rate_limiter (route) โ€” no pii tag + // both allow โ†’ overall allowed + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let (r2, _) = mgr + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "send_email", None, &[]), + None, + ) + .await; + assert!(r2.continue_processing); // no deny plugin fires + } + + #[tokio::test] + async fn test_routing_disabled_fires_all_plugins() { + // Same plugins but routing disabled โ€” all fire regardless of entity + let yaml = r#" +plugins: + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 + - name: allower + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 20 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Even with meta, routing disabled โ†’ all plugins fire โ†’ denier wins + let p: Box = Box::new(TestPayload { value: "t".into() }); + let (result, _) = mgr + .invoke_by_name( + "test_hook", + p, + make_meta("tool", "anything", None, &[]), + None, + ) + .await; + assert!(!result.continue_processing); // denier fires (all plugins active) + } + + #[tokio::test] + async fn test_routing_no_meta_fires_all_plugins() { + // Routing enabled but no meta on extensions โ†’ fallback to all + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allower] +plugins: + - name: allower + kind: test/allow + hooks: [test_hook] + mode: sequential + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation + plugins: + - denier +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // No meta โ†’ all plugins fire (both allower and denier) + let p: Box = Box::new(TestPayload { value: "t".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", p, Extensions::default(), None) + .await; + // No meta โ†’ no route resolution โ†’ both plugins fire. The denier + // running is observable (the deny propagates to the result), so + // assert that โ€” proves route filtering didn't accidentally hide it. + assert!( + !result.continue_processing, + "denier should run when no meta is provided (route filtering bypassed)", + ); + assert!( + result.violation.is_some(), + "deny should produce a violation" + ); + } + + #[tokio::test] + async fn test_routing_wildcard_catches_unmatched() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] +plugins: + - name: identity + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: specific_plugin + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 + - name: fallback_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation + plugins: + - specific_plugin + - tool: "*" + plugins: + - fallback_plugin +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // get_compensation matches exact route โ†’ specific_plugin (deny) + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let (r1, _) = mgr + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(!r1.continue_processing); // specific_plugin denies + + // unknown_tool matches wildcard โ†’ fallback_plugin (allow) + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let (r2, _) = mgr + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "unknown_tool", None, &[]), + None, + ) + .await; + assert!(r2.continue_processing); // fallback_plugin allows + } + + #[tokio::test] + async fn test_routing_host_tags_activate_policy_groups() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] + urgent: + plugins: [denier] +plugins: + - name: identity + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Without urgent tag โ†’ only identity fires โ†’ allowed + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let (r1, _) = mgr + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(r1.continue_processing); + + // Clear cache so new tags take effect + mgr.clear_routing_cache(); + + // With urgent tag from host โ†’ denier also fires โ†’ denied + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let (r2, _) = mgr + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "get_compensation", None, &["urgent"]), + None, + ) + .await; + assert!(!r2.continue_processing); + } + + #[tokio::test] + async fn test_routing_works_with_typed_invoke() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allower] + pii: + plugins: [denier] +plugins: + - name: allower + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation + meta: + tags: [pii] + - tool: send_email +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Typed invoke for get_compensation โ€” pii tag activates denier โ†’ denied + let (r1, _) = mgr + .invoke::( + TestPayload { value: "t".into() }, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(!r1.continue_processing); + + // Typed invoke for send_email โ€” no pii tag โ†’ only allower โ†’ allowed + let (r2, _) = mgr + .invoke::( + TestPayload { value: "t".into() }, + make_meta("tool", "send_email", None, &[]), + None, + ) + .await; + assert!(r2.continue_processing); + } + + // -- Executor tier validation tests -- + + /// Handler that modifies extensions via cow_copy โ€” adds a label. + struct LabelAdderHandler; + + #[async_trait] + impl AnyHookHandler for LabelAdderHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let mut ext = extensions.cow_copy(); + if let Some(ref mut sec) = ext.security { + sec.add_label("PLUGIN_ADDED"); + } + let mut result: PluginResult = PluginResult::allow(); + result.modified_extensions = Some(ext); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + /// Handler that tampers with an immutable extension slot. + struct ImmutableTampererHandler; + + #[async_trait] + impl AnyHookHandler for ImmutableTampererHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let mut ext = extensions.cow_copy(); + // Tamper: replace the immutable request extension + ext.request = Some(std::sync::Arc::new(crate::extensions::RequestExtension { + request_id: Some("TAMPERED".into()), + ..Default::default() + })); + let mut result: PluginResult = PluginResult::allow(); + result.modified_extensions = Some(ext); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + #[tokio::test] + async fn test_executor_accepts_valid_label_addition() { + let mgr = PluginManager::default(); + let mut config = make_config("label-adder", 10, PluginMode::Sequential); + config.capabilities = ["append_labels".to_string(), "read_labels".to_string()].into(); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(LabelAdderHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + mgr.initialize().await.unwrap(); + + // Build extensions with a security label + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("ORIGINAL"); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert!(result.continue_processing); + // The plugin added "PLUGIN_ADDED" โ€” should be accepted (monotonic superset) + let modified = result.modified_extensions.as_ref().unwrap(); + let sec = modified.security.as_ref().unwrap(); + assert!(sec.has_label("ORIGINAL")); + assert!(sec.has_label("PLUGIN_ADDED")); + } + + #[tokio::test] + async fn test_executor_rejects_immutable_tampering() { + let 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, Box> { + // Check if security is visible + if extensions.security.is_some() { + self.saw_security + .store(true, std::sync::atomic::Ordering::SeqCst); + } + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let mgr = PluginManager::default(); + // No security capabilities declared + let config = make_config("no-sec-caps", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(SecurityCheckerHandler { + saw_security: saw_security.clone(), + }); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + mgr.initialize().await.unwrap(); + + // Build extensions WITH security data + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("SECRET"); + security.subject = Some(crate::extensions::security::SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert!(result.continue_processing); + // Plugin should NOT have seen security โ€” no capabilities declared + // Security is still there but labels and subject are empty/none + // (filter_extensions strips gated fields) + // The saw_security flag checks if the security Option itself was Some + // With filter_extensions, security IS Some but with empty labels and no subject + // So saw_security will be true, but the content is filtered + } + + // ----------------------------------------------------------------------- + // Awaiting handler tests + // + // `HookHandler` is async by design. These tests cover handlers + // that genuinely `.await` inside the body โ€” sleeps, yields, and + // co-registration with handlers whose body has no `.await` at all. + // ----------------------------------------------------------------------- + + /// Plugin that genuinely awaits inside its handler. Increments a + /// shared counter after the await resolves so the test can verify + /// the handler ran end-to-end and observed its async point. + struct AsyncCounterPlugin { + cfg: PluginConfig, + counter: Arc, + } + + #[async_trait] + impl Plugin for AsyncCounterPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + impl HookHandler for AsyncCounterPlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_micros(1)).await; + self.counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + PluginResult::allow() + } + } + + /// Verifies that a handler that genuinely `.await`s gets driven + /// to completion before its result is observed. + #[tokio::test] + async fn test_async_handler_registers_and_invokes() { + let mgr = PluginManager::default(); + let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let cfg = make_config("async-counter", 10, PluginMode::Sequential); + let plugin = Arc::new(AsyncCounterPlugin { + cfg: cfg.clone(), + counter: counter.clone(), + }); + + // Same call path as sync plugins โ€” no `register_async_handler`. + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert!(result.violation.is_none()); + // Counter increments only after the await resolves, so a non-zero + // value proves the future was actually driven to completion. + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "async handler should have run once", + ); + } + + /// A handler with no `.await` (AllowPlugin) and a handler that + /// genuinely awaits (AsyncCounterPlugin) co-register on the same + /// hook via the same `register_handler` call. Both run in priority + /// order. + #[tokio::test] + async fn test_mixed_sync_and_async_handlers_in_same_hook() { + let mgr = PluginManager::default(); + let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + + let sync_cfg = make_config("sync-allow", 10, PluginMode::Sequential); + let sync_plugin = Arc::new(AllowPlugin { + cfg: sync_cfg.clone(), + }); + mgr.register_handler::(sync_plugin, sync_cfg) + .unwrap(); + + let async_cfg = make_config("async-counter", 20, PluginMode::Sequential); + let async_plugin = Arc::new(AsyncCounterPlugin { + cfg: async_cfg.clone(), + counter: counter.clone(), + }); + mgr.register_handler::(async_plugin, async_cfg) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "awaiting plugin should have run alongside the non-awaiting plugin", + ); + } +} diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs new file mode 100644 index 00000000..dd743a24 --- /dev/null +++ b/crates/cpex-core/src/plugin.rs @@ -0,0 +1,553 @@ +// 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<(), Box> { Ok(()) } +/// async fn shutdown(&self) -> Result<(), Box> { Ok(()) } +/// } +/// +/// impl CmfHookHandler for MyPlugin { +/// fn cmf_hook(&self, payload: MessagePayload, ext: &Extensions, ctx: &PluginContext) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// ``` +/// +/// # Trust Model +/// +/// The manager wraps each plugin in a `PluginRef` with an authoritative +/// config from the config loader. The executor reads scheduling decisions +/// (mode, priority, hooks, capabilities) from the `PluginRef` โ€” never +/// from `plugin.config()`. The plugin's own `config()` is available for +/// the plugin's reading during hook execution. +/// +/// # Implementors +/// +/// - Native Rust plugins (implement directly) +/// - `cpex-hosts::wasm` (bridges to WASM guest via wasmtime) +/// - `cpex-hosts::python` (bridges to Python plugin classes via PyO3) +/// - `cpex-hosts::native` (bridges to dlopen'd shared libraries) +#[async_trait] +pub trait Plugin: Send + Sync { + /// Returns the plugin's configuration. + /// + /// Available for the plugin's own reading during hook execution. + /// The manager/executor never reads this โ€” they use the authoritative + /// config from `PluginRef.trusted_config()`. + fn config(&self) -> &PluginConfig; + + /// One-time initialization after loading. + /// + /// Called before any hook invocations. Use this to establish + /// connections, load resources, or validate configuration. + /// Default implementation does nothing. + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + /// Graceful shutdown. + /// + /// Called once during teardown. Use this to flush buffers, close + /// connections, or release resources. + /// Default implementation does nothing. + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// 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, +} + +impl PluginConfig { + /// Whether this plugin's `conditions` allow it to fire for the given + /// request `Extensions`. Used in legacy mode (`routing_enabled: false`) + /// to filter which plugins run per request โ€” mirrors the Python + /// implementation's per-plugin condition filtering. + /// + /// Semantics: + /// - Empty `conditions` Vec โ†’ fire always (no restriction). + /// - Non-empty โ†’ fire if ANY condition matches (OR across the list, + /// AND within each individual condition). + /// + /// Field-source mapping (see project memory `project_conditions_field_mapping`): + /// - `server_ids` โ† `extensions.mcp.{tool|resource|prompt}.server_id` + /// - `tenant_ids` โ† `extensions.security.subject.claims["tenant"]` + /// - `tools|prompts|resources` โ† `extensions.meta.entity_name` (when matching `entity_type`) + /// - `agents` โ† `extensions.agent.agent_id` + /// - `user_patterns` โ† `extensions.security.subject.id` (glob match) + /// - `content_types` โ† `extensions.mcp.resource.mime_type` + pub fn passes_conditions(&self, extensions: &crate::hooks::payload::Extensions) -> bool { + if self.conditions.is_empty() { + return true; + } + + // Source values once from the extensions tree. + let server_id = extensions.mcp.as_ref().and_then(|m| { + m.tool + .as_ref() + .and_then(|t| t.server_id.as_deref()) + .or_else(|| m.resource.as_ref().and_then(|r| r.server_id.as_deref())) + .or_else(|| m.prompt.as_ref().and_then(|p| p.server_id.as_deref())) + }); + let tenant_id = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|sub| sub.claims.get("tenant")) + .map(|s| s.as_str()); + let entity_name = extensions + .meta + .as_ref() + .and_then(|m| m.entity_name.as_deref()); + let entity_type = extensions + .meta + .as_ref() + .and_then(|m| m.entity_type.as_deref()); + let (tool, prompt, resource) = match entity_type { + Some("tool") => (entity_name, None, None), + Some("prompt") => (None, entity_name, None), + Some("resource") => (None, None, entity_name), + _ => (None, None, None), + }; + let agent = extensions + .agent + .as_ref() + .and_then(|a| a.agent_id.as_deref()); + let user = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|sub| sub.id.as_deref()); + let content_type = extensions + .mcp + .as_ref() + .and_then(|m| m.resource.as_ref()) + .and_then(|r| r.mime_type.as_deref()); + + let ctx = MatchContext { + server_id, + tenant_id, + tool, + prompt, + resource, + agent, + user, + content_type, + }; + self.conditions.iter().any(|c| c.matches(&ctx)) + } +} + +fn default_priority() -> i32 { + 100 +} + +// --------------------------------------------------------------------------- +// 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>, +} + +/// Bundle of optional context values used to evaluate a `PluginCondition`. +/// +/// Each field corresponds to one of the condition's gates. `None` means +/// "no value sourced from the extensions tree"; the condition then +/// rejects when the corresponding `Some(set)` is set on the condition +/// (i.e., the gate was specified but couldn't be evaluated). +/// +/// Replaces an 8-arg `matches(...)` call where every arg was +/// `Option<&str>` and could be misordered silently. +#[derive(Debug, Default, Clone, Copy)] +pub struct MatchContext<'a> { + pub server_id: Option<&'a str>, + pub tenant_id: Option<&'a str>, + pub tool: Option<&'a str>, + pub prompt: Option<&'a str>, + pub resource: Option<&'a str>, + pub agent: Option<&'a str>, + pub user: Option<&'a str>, + pub content_type: Option<&'a str>, +} + +impl PluginCondition { + /// Whether this condition matches the given context. + /// + /// A field that is `None` is treated as "any" (no restriction). + /// A `Some(set)` field matches if the given value is in the set + /// (exact match for ID-shaped fields; glob match via `wildmatch` + /// for `user_patterns`). + /// All specified fields must match โ€” AND semantics within one condition. + pub fn matches(&self, ctx: &MatchContext<'_>) -> bool { + let MatchContext { + server_id, + tenant_id, + tool, + prompt, + resource, + agent, + user, + content_type, + } = *ctx; + let check_set = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, // not specified โ€” matches anything + Some(set) => match value { + Some(v) => set.contains(v), + None => false, // field required but no value provided + }, + } + }; + + // user_patterns: list of globs. Match if any pattern matches the user. + let check_patterns = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, + Some(patterns) => match value { + Some(v) => patterns + .iter() + .any(|p| wildmatch::WildMatch::new(p).matches(v)), + None => false, + }, + } + }; + + // content_types: list of exact strings. + let check_list = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, + Some(list) => match value { + Some(v) => list.iter().any(|s| s == v), + None => false, + }, + } + }; + + check_set(&self.server_ids, server_id) + && check_set(&self.tenant_ids, tenant_id) + && check_set(&self.tools, tool) + && check_set(&self.prompts, prompt) + && check_set(&self.resources, resource) + && check_set(&self.agents, agent) + && check_patterns(&self.user_patterns, user) + && check_list(&self.content_types, content_type) + } +} + +// --------------------------------------------------------------------------- +// 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")] +#[non_exhaustive] +pub enum PluginMode { + /// Policy enforcement + transformation. Serial, chained. Can block and modify. + #[default] + Sequential, + + /// Data shaping (PII redaction, normalization). Serial, chained. Can modify, cannot block. + Transform, + + /// Observation and logging. Serial, read-only. Cannot block or modify. + Audit, + + /// Independent policy gates. Parallel, fail-fast. Can block, cannot modify. + Concurrent, + + /// Telemetry and async side effects. Background tasks. Cannot block or modify. + FireAndForget, + + /// Plugin is disabled โ€” skipped during execution. + Disabled, +} + +impl PluginMode { + /// Whether this mode allows the plugin to block the pipeline. + pub fn can_block(&self) -> bool { + matches!(self, Self::Sequential | Self::Concurrent) + } + + /// Whether this mode allows the plugin to modify the payload. + pub fn can_modify(&self) -> bool { + matches!(self, Self::Sequential | Self::Transform) + } + + /// Whether the framework waits for this plugin to complete. + pub fn is_awaited(&self) -> bool { + !matches!(self, Self::FireAndForget | Self::Disabled) + } +} + +impl fmt::Display for PluginMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sequential => write!(f, "sequential"), + Self::Transform => write!(f, "transform"), + Self::Audit => write!(f, "audit"), + Self::Concurrent => write!(f, "concurrent"), + Self::FireAndForget => write!(f, "fire_and_forget"), + Self::Disabled => write!(f, "disabled"), + } + } +} + +// --------------------------------------------------------------------------- +// Error Handling 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")] +#[non_exhaustive] +pub enum OnError { + /// Pipeline halts and error propagates. Fail-safe enforcement. + #[default] + Fail, + + /// Error logged, pipeline continues. For non-critical plugins. + Ignore, + + /// Plugin auto-disabled after error. Prevents repeated failures. + Disable, +} + +impl fmt::Display for OnError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fail => write!(f, "fail"), + Self::Ignore => write!(f, "ignore"), + Self::Disable => write!(f, "disable"), + } + } +} diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs new file mode 100644 index 00000000..0b4990c1 --- /dev/null +++ b/crates/cpex-core/src/registry.rs @@ -0,0 +1,732 @@ +// Location: ./crates/cpex-core/src/registry.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin and hook registries. +// +// PluginRef wraps a plugin implementation with the manager's +// authoritative config. The config comes from the config loader, +// NOT from the plugin โ€” the plugin never provides its own config +// to the manager. This prevents a plugin from tampering with its +// own priority, mode, or capabilities. +// +// Trust flows one direction: +// config loader โ†’ manager โ†’ PluginRef โ†’ executor +// The plugin is just a recipient, not a source. +// +// The registry supports two registration paths: +// +// 1. **Typed** (`register::()`) โ€” for Rust plugins implementing +// a handler trait generated by define_hook!. The handler is stored +// type-erased alongside the PluginRef. At dispatch time, the typed +// path (`invoke::()`) downcasts back; the dynamic path +// (`invoke_by_name()`) calls through the type-erased interface. +// +// 2. **Name-based** (`register_for_names::()`) โ€” same handler +// registered under multiple hook names (the CMF pattern). +// +// Mirrors the Python framework's PluginRef and PluginInstanceRegistry +// in cpex/framework/base.py and cpex/framework/registry.py. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use std::sync::Arc; +use uuid::Uuid; + +use crate::context::PluginContext; +use crate::hooks::payload::{Extensions, PluginPayload}; +use crate::hooks::trait_def::HookTypeDef; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig, PluginMode}; + +// --------------------------------------------------------------------------- +// 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. + /// Stored as `Uuid` (16 bytes, `Copy`) rather than a 36-char `String` + /// to avoid heap allocation per registered plugin and to give + /// downstream `HashMap` keys fixed-size hashing. + id: Uuid, + + /// Runtime circuit breaker โ€” set to true when `on_error: Disable` + /// triggers. Once set, `mode()` returns `Disabled` and the plugin + /// is skipped by `group_by_mode()` on all subsequent invocations. + /// Uses `Arc` so clones (in HookEntry) share the same flag. + disabled: Arc, +} + +impl PluginRef { + /// Create a new PluginRef with an independently-sourced config. + /// + /// The `trusted_config` must come from the config loader or manager, + /// NOT from `plugin.config()`. The plugin may hold its own copy + /// for reading during execute(), but the manager never consults it. + pub fn new(plugin: Arc, trusted_config: PluginConfig) -> Self { + Self { + plugin, + trusted_config, + id: Uuid::new_v4(), + disabled: Arc::new(AtomicBool::new(false)), + } + } + + /// The authoritative config used by the executor for all decisions. + pub fn trusted_config(&self) -> &PluginConfig { + &self.trusted_config + } + + /// The plugin implementation (for calling initialize/shutdown). + pub fn plugin(&self) -> &Arc { + &self.plugin + } + + /// Unique identifier assigned at registration. + /// Returned by value โ€” `Uuid` is `Copy` (16 bytes). + pub fn id(&self) -> Uuid { + self.id + } + + /// Convenience: plugin name from the trusted config. + pub fn name(&self) -> &str { + &self.trusted_config.name + } + + /// Effective mode โ€” returns `Disabled` if the runtime circuit breaker + /// has tripped, otherwise returns the configured mode. + /// + /// `Acquire` on the load pairs with `Release` on the disable() store + /// so weak-memory-ordering hardware (ARM64) propagates the disable + /// promptly across threads. + pub fn mode(&self) -> PluginMode { + if self.disabled.load(Ordering::Acquire) { + PluginMode::Disabled + } else { + self.trusted_config.mode + } + } + + /// Runtime-disable this plugin (one-way circuit breaker). + /// + /// Called by the executor when a plugin errors with `on_error: Disable`. + /// All clones of this PluginRef (in HookEntry, etc.) share the same + /// `AtomicBool`, so the disable is visible across the system. + /// + /// `Release` ordering establishes a happens-before with `Acquire` + /// loads in `is_disabled()` and `mode()` โ€” required for correctness + /// on weak-memory hardware (ARM64) where `Relaxed` allows the new + /// value to remain unobserved by other threads for an unbounded window. + pub fn disable(&self) { + self.disabled.store(true, Ordering::Release); + } + + /// Whether this plugin has been runtime-disabled. + /// `Acquire` pairs with the `Release` in `disable()` (see `mode()`). + pub fn is_disabled(&self) -> bool { + self.disabled.load(Ordering::Acquire) + } + + /// Convenience: plugin priority from the trusted config. + pub fn priority(&self) -> i32 { + self.trusted_config.priority + } +} + +// --------------------------------------------------------------------------- +// Type-Erased Hook Handler +// --------------------------------------------------------------------------- + +/// Type-erased interface for calling a hook handler. +/// +/// The executor uses this to dispatch hooks without knowing the +/// concrete handler trait at compile time. Each handler wraps a +/// plugin that implements a specific handler trait (e.g., +/// `CmfHookHandler`) and translates between type-erased payloads +/// and the typed handler method. +/// +/// The executor dispatches through this trait for all five phases. +/// The handler receives a borrowed payload โ€” the framework retains +/// ownership. Plugins clone only when modifying. +/// +/// `invoke` is async so that plugins can perform I/O (HTTP calls, +/// Redis, vault lookups) without blocking the tokio runtime, and +/// so that `tokio::time::timeout` can actually observe and cancel +/// long-running handlers. +#[async_trait::async_trait] +pub trait AnyHookHandler: Send + Sync { + /// Call the handler with a borrowed payload. + /// + /// Returns an `ErasedResultFields` (see executor module) wrapped + /// as `Box`. If the handler modified the payload, the + /// modified copy is in `ErasedResultFields.modified_payload`. + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box>; + + /// 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. +/// +/// `plugin_ref` is `Arc` so cloning a `HookEntry` is two +/// reference-count bumps rather than a deep clone of the embedded +/// `PluginConfig`. `group_by_mode` (called once per invoke) clones N +/// entries โ€” keeping that cheap matters at high request rates. +#[derive(Clone)] +pub struct HookEntry { + /// The plugin wrapper with authoritative config. + pub plugin_ref: Arc, + + /// The type-erased handler for this specific hook. + pub handler: Arc, +} + +// --------------------------------------------------------------------------- +// 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.). +/// +/// `Clone` is cheap-ish: the HashMaps duplicate, but their values are all +/// `Arc`-counted (`Arc`, `Arc`), so the +/// inner data is shared. Used by `PluginManager`'s `ArcSwap` snapshot +/// pattern, where every mutating method clones the registry, mutates the +/// clone, and atomically swaps in a new snapshot. +#[derive(Clone)] +pub struct PluginRegistry { + /// Plugins keyed by name (for lookup and lifecycle). Wrapped in `Arc` + /// so the same instance is shared with every `HookEntry` in + /// `hook_index` โ€” registering a plugin allocates one `PluginRef`, + /// not one per hook. + plugins: HashMap>, + + /// Hook name โ†’ list of HookEntries, sorted by priority. + hook_index: HashMap>, +} + +impl PluginRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + hook_index: HashMap::new(), + } + } + + /// Register a typed hook handler for its primary hook name. + /// + /// The handler is registered under `H::NAME`. The `config` must + /// come from the config loader โ€” not from the plugin. The plugin + /// must implement the handler trait generated by `define_hook!`. + /// + /// # Type Parameters + /// + /// - `H` โ€” the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// - `plugin` โ€” the plugin implementation (must also implement the handler trait). + /// - `config` โ€” authoritative config from the config loader. + /// - `handler` โ€” type-erased handler wrapping the plugin's handler trait impl. + pub fn register( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, &[H::NAME]) + } + + /// Register a typed hook handler for multiple hook names. + /// + /// This is the CMF pattern โ€” one handler trait impl covers multiple + /// hook names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). + /// + /// # Arguments + /// + /// - `plugin` โ€” the plugin implementation. + /// - `config` โ€” authoritative config from the config loader. + /// - `handler` โ€” type-erased handler. + /// - `names` โ€” hook names to register under. + pub fn register_for_names( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, names) + } + + /// Register a plugin with a handler for multiple hook names. + /// + /// Like `register_for_names` but without requiring a `HookTypeDef` + /// type parameter. Used by the config-driven factory path where + /// the hook type is not known at compile time โ€” the factory + /// provides the handler directly. + pub fn register_for_names_with_handler( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, names) + } + + /// Register a plugin with multiple handlers, each for a specific hook. + /// + /// Used when a plugin implements multiple hook types with different + /// payloads (e.g., `ToolPreInvoke` and `ToolPostInvoke`). Each + /// handler is registered under its paired hook name. + /// + /// The plugin is registered once in the name index. Each handler + /// gets its own `HookEntry` in the hook index under the specified name. + pub fn register_multi_handler( + &mut self, + plugin: Arc, + config: PluginConfig, + handlers: Vec<(&str, Arc)>, + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = Arc::new(PluginRef::new(plugin, config)); + + for (hook_name, handler) in &handlers { + let hook_type = HookType::new(*hook_name); + let entry = HookEntry { + plugin_ref: Arc::clone(&plugin_ref), + handler: Arc::clone(handler), + }; + self.hook_index.entry(hook_type).or_default().push(entry); + } + + // Sort each affected hook's entry list by trusted priority + for (hook_name, _) in &handlers { + let hook_type = HookType::new(*hook_name); + if let Some(entries) = self.hook_index.get_mut(&hook_type) { + entries.sort_by_key(|e| e.plugin_ref.priority()); + } + } + + self.plugins.insert(name, plugin_ref); + Ok(()) + } + + /// Internal: register handler under one or more hook names. + fn register_for_names_inner( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = Arc::new(PluginRef::new(plugin, config)); + + // Add to hook index for each specified hook name + for hook_name in names { + let hook_type = HookType::new(*hook_name); + let entry = HookEntry { + plugin_ref: Arc::clone(&plugin_ref), + handler: Arc::clone(&handler), + }; + self.hook_index.entry(hook_type).or_default().push(entry); + } + + // Sort each affected hook's entry list by trusted priority + for hook_name in names { + let hook_type = HookType::new(*hook_name); + if let Some(entries) = self.hook_index.get_mut(&hook_type) { + entries.sort_by_key(|e| e.plugin_ref.priority()); + } + } + + self.plugins.insert(name, plugin_ref); + Ok(()) + } + + /// Unregister a plugin by name. + /// + /// Removes the PluginRef from the name index and all HookEntries + /// from the hook index. Returns the (Arc-wrapped) PluginRef if found. + pub fn unregister(&mut self, name: &str) -> Option> { + let plugin_ref = self.plugins.remove(name)?; + + // 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. Returns an `Arc` clone so callers + /// don't hold borrows on internal storage โ€” works with snapshot-based + /// dispatch where the registry may sit behind a transient guard. + pub fn get(&self, name: &str) -> Option> { + self.plugins.get(name).map(Arc::clone) + } + + /// Returns all HookEntries for a given hook name, sorted by priority. + /// + /// Returns an empty slice if no plugins are registered for the hook. + pub fn entries_for_hook(&self, hook_type: &HookType) -> &[HookEntry] { + self.hook_index + .get(hook_type) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_type: &HookType) -> bool { + self.hook_index + .get(hook_type) + .map(|v| !v.is_empty()) + .unwrap_or(false) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// All registered plugin names. Returns owned `String`s so callers + /// don't hold borrows on internal storage โ€” works with snapshot-based + /// dispatch where the registry may sit behind a transient guard. + pub fn plugin_names(&self) -> Vec { + self.plugins.keys().cloned().collect() + } +} + +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 type GroupedHookEntries = ( + Vec, + Vec, + Vec, + Vec, + Vec, +); + +pub fn group_by_mode(entries: &[HookEntry]) -> GroupedHookEntries { + let mut sequential = Vec::new(); + let mut transform = Vec::new(); + let mut audit = Vec::new(); + let mut concurrent = Vec::new(); + let mut fire_and_forget = Vec::new(); + + for entry in entries { + match entry.plugin_ref.mode() { + PluginMode::Sequential => sequential.push(entry.clone()), + PluginMode::Transform => transform.push(entry.clone()), + PluginMode::Audit => audit.push(entry.clone()), + PluginMode::Concurrent => concurrent.push(entry.clone()), + PluginMode::FireAndForget => fire_and_forget.push(entry.clone()), + PluginMode::Disabled => {} // skip + } + } + + (sequential, transform, audit, concurrent, fire_and_forget) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::PluginError; + use crate::hooks::payload::PluginPayload; + use crate::hooks::PluginResult; + use async_trait::async_trait; + + // -- Test payload and hook type -- + + #[derive(Debug, Clone)] + #[allow(dead_code)] // test fixture โ€” typed shape is the point, not field reads + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + // -- Test handler (type-erased wrapper) -- + + /// A simple AnyHookHandler that wraps a function for testing. + struct TestHandler; + + #[async_trait] + impl AnyHookHandler for TestHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Test plugin -- + + struct TestPlugin { + cfg: PluginConfig, + } + + fn make_config(name: &str, hooks: Vec<&str>, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: hooks.into_iter().map(String::from).collect(), + mode: PluginMode::Sequential, + priority, + on_error: Default::default(), + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } + } + + impl TestPlugin { + fn new(cfg: PluginConfig) -> Self { + Self { cfg } + } + } + + #[async_trait] + impl Plugin for TestPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } + } + + // -- Tests -- + + #[test] + fn test_register_typed_and_lookup() { + let mut reg = PluginRegistry::new(); + let config = make_config("test-plugin", vec!["test_hook"], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + // Use register_for_names_inner directly since we don't have a real HookTypeDef + reg.register_for_names_inner(plugin, config, handler, &["test_hook"]) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + assert!(reg.get("test-plugin").is_some()); + assert!(reg.has_hooks_for(&HookType::new("test_hook"))); + assert!(!reg.has_hooks_for(&HookType::new("other_hook"))); + + let entries = reg.entries_for_hook(&HookType::new("test_hook")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].plugin_ref.name(), "test-plugin"); + } + + #[test] + fn test_register_for_multiple_names() { + let mut reg = PluginRegistry::new(); + let config = make_config("cmf-plugin", vec![], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner( + plugin, + config, + handler, + &[ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + ], + ) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + assert!(reg.has_hooks_for(&HookType::new("cmf.tool_pre_invoke"))); + assert!(reg.has_hooks_for(&HookType::new("cmf.tool_post_invoke"))); + assert!(reg.has_hooks_for(&HookType::new("cmf.llm_input"))); + assert!(!reg.has_hooks_for(&HookType::new("cmf.llm_output"))); + } + + #[test] + fn test_duplicate_registration_fails() { + let mut reg = PluginRegistry::new(); + let c1 = make_config("dup", vec![], 10); + let c2 = make_config("dup", vec![], 20); + let p1 = Arc::new(TestPlugin::new(c1.clone())); + let p2 = Arc::new(TestPlugin::new(c2.clone())); + let h1: Arc = Arc::new(TestHandler); + let h2: Arc = Arc::new(TestHandler); + + assert!(reg + .register_for_names_inner(p1, c1, h1, &["hook_a"]) + .is_ok()); + assert!(reg + .register_for_names_inner(p2, c2, h2, &["hook_a"]) + .is_err()); + } + + #[test] + fn test_priority_ordering_uses_trusted_config() { + let mut reg = PluginRegistry::new(); + let c_low = make_config("low", vec![], 100); + let c_high = make_config("high", vec![], 10); + let p_low = Arc::new(TestPlugin::new(c_low.clone())); + let p_high = Arc::new(TestPlugin::new(c_high.clone())); + let h1: Arc = Arc::new(TestHandler); + let h2: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]) + .unwrap(); + reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]) + .unwrap(); + + let entries = reg.entries_for_hook(&HookType::new("hook_a")); + assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first + assert_eq!(entries[1].plugin_ref.name(), "low"); // priority 100 second + } + + #[test] + fn test_unregister() { + let mut reg = PluginRegistry::new(); + let config = make_config("removable", vec![], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner(plugin, config, handler, &["hook_a"]) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + reg.unregister("removable"); + assert_eq!(reg.plugin_count(), 0); + assert!(!reg.has_hooks_for(&HookType::new("hook_a"))); + } + + #[test] + fn test_plugin_ref_id_is_unique() { + let c1 = make_config("a", vec![], 10); + let c2 = make_config("b", vec![], 10); + let p1 = Arc::new(TestPlugin::new(c1.clone())); + let p2 = Arc::new(TestPlugin::new(c2.clone())); + let ref1 = PluginRef::new(p1, c1); + let ref2 = PluginRef::new(p2, c2); + assert_ne!(ref1.id(), ref2.id()); + } + + #[test] + fn test_tampered_plugin_config_ignored() { + let trusted = make_config("sneaky", vec![], 100); + let mut tampered = trusted.clone(); + tampered.priority = 1; + let plugin = Arc::new(TestPlugin::new(tampered)); + + let plugin_ref = PluginRef::new(plugin, trusted); + assert_eq!(plugin_ref.priority(), 100); + } + + #[tokio::test] + async fn test_handler_invoke() { + let handler = TestHandler; + let payload = TestPayload { + value: "test".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::new(); + + let result = handler + .invoke(&payload as &dyn PluginPayload, &ext, &mut ctx) + .await + .unwrap(); + let fields = crate::executor::extract_erased(result).unwrap(); + assert!(fields.continue_processing); + } +} diff --git a/crates/cpex-dynamic-plugin/Cargo.toml b/crates/cpex-dynamic-plugin/Cargo.toml new file mode 100644 index 00000000..484ff9a2 --- /dev/null +++ b/crates/cpex-dynamic-plugin/Cargo.toml @@ -0,0 +1,76 @@ +# Location: ./crates/cpex-dynamic-plugin/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# cpex-dynamic-plugin โ€” load Rust cdylib plugins at runtime via +# libloading. +# +# # Design notes +# +# See `docs/specs/cpex-rust-spec.md` ยง17 (Dynamic Plugin Loading). +# Plugin and host compile against the same `cpex-core` version +# (same-version-only Rust ABI). `Arc` crosses +# the dlopen boundary as the stable vtable type; no serialization +# of payloads, extensions, or results. +# +# # Two roles, one crate, feature-gated +# +# This crate has two audiences: +# +# * **Plugin authors** โ€” depend on this crate to get the entry- +# point types + helper macros. The default feature set is what +# they need; libloading is NOT pulled. +# * **Hosts** โ€” depend on this crate with the `host` feature to +# pull in libloading + `DynamicPluginFactory`. Hosts register +# the factory under a kind (default `"dynamic"`) and operators +# reference dynamic plugins via that kind in unified config. +# +# Keeping it one crate avoids two-crate version-skew issues +# (plugin and host always see the same ABI types because they're +# in the same package). + +[package] +name = "cpex-dynamic-plugin" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +# Plugin-side default โ€” no libloading, no extra deps. Plugin +# authors get just the ABI types + helper macros. +default = [] +# Host-side opt-in โ€” pulls libloading + the DynamicPluginFactory. +host = ["dep:libloading"] + +[dependencies] +cpex-core = { path = "../cpex-core" } + +# libloading is the de-facto Rust crate for dlopen / LoadLibraryW. +# Optional: only pulled when the `host` feature is enabled, so +# plugin-side builds stay light. +libloading = { version = "0.8", optional = true } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +# `paste` is used by the `cpex_dynamic_plugins!` macro to build +# `cpex_plugin_create_` identifiers from the user-supplied +# entry name. Plugin authors don't reference paste directly; the +# macro re-exports it under `$crate::paste`. +paste = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +# Depending on the example plugin crate triggers cargo to build +# its cdylib artifact alongside this crate's tests. We don't +# actually USE anything from the rlib at the Rust level โ€” the +# integration test loads the built `.so` / `.dylib` / `.dll` +# directly via libloading. The dep edge is purely a build-order +# trigger. +cpex-dynamic-plugin-example = { path = "examples/single-plugin" } +cpex-dynamic-plugin-multi-handler-example = { path = "examples/multi-handler" } +cpex-dynamic-plugin-multi-plugin-example = { path = "examples/multi-plugin" } diff --git a/crates/cpex-dynamic-plugin/README.md b/crates/cpex-dynamic-plugin/README.md new file mode 100644 index 00000000..f71072af --- /dev/null +++ b/crates/cpex-dynamic-plugin/README.md @@ -0,0 +1,654 @@ +# cpex-dynamic-plugin + +Load Rust CPEX plugins at runtime from `.so` / `.dylib` / `.dll` +files. Plugin authors write the same `async fn handle(...)` code +they would for an in-tree plugin; the only difference is the +plugin compiles as a `cdylib` and the host loads it via `libloading`. + +**No serialization across the FFI boundary.** Payloads and +extensions cross as pointers through `Arc` โ€” +same in-memory representation in plugin and host. All immutability +guarantees, capability gating, monotonic-set protections, and +panic isolation from in-tree plugins apply identically. See +[`docs/specs/cpex-rust-spec.md` ยง17][spec-ยง17] for the architecture +rationale. + +[spec-ยง17]: ../../docs/specs/cpex-rust-spec.md + +--- + +## Quick start + +### 1. Project layout + +``` +my-plugin/ +โ”œโ”€โ”€ Cargo.toml +โ””โ”€โ”€ src/ + โ””โ”€โ”€ lib.rs +``` + +A dynamic plugin is just a regular Cargo crate with `crate-type = +["cdylib"]`. Most plugins are a single source file plus the +manifest. + +### 2. `Cargo.toml` + +```toml +[package] +name = "my-rate-limiter" +version = "0.1.0" +edition = "2021" + +[lib] +# `cdylib` is what the host dlopens. Add `rlib` too if other Rust +# crates need to depend on this plugin as a normal library (rare; +# usually only needed for the workspace-internal pattern where +# tests dev-depend on a plugin to trigger the cdylib build). +crate-type = ["cdylib"] + +[dependencies] +# Both deps MUST be pinned to the same versions the HOST is built +# against. Same-version-only Rust ABI is the load-bearing constraint +# (see "ABI versioning" below). +cpex-core = "..." # whatever your host uses +cpex-dynamic-plugin = "..." # same +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +``` + +### 3. `src/lib.rs` + +```rust +use std::sync::Arc; +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; + +/// Typed *view* of the same fields the operator put under +/// `config:` in their YAML โ€” i.e. of `cfg.config`. We deserialize +/// once at construction so `handle()` doesn't re-parse JSON on +/// the hot path AND so structural mismatches surface as a +/// startup-time `InitializationError` instead of at first invoke. +/// +/// This is **not** a separate config source. The operator only +/// ever writes one `config:` block per plugin; `ParsedConfig` is +/// just how this plugin chooses to materialize that block in +/// memory. +#[derive(serde::Deserialize)] +struct ParsedConfig { + max_per_second: u32, + #[serde(default = "default_burst")] + burst: u32, +} + +fn default_burst() -> u32 { 10 } + +struct MyRateLimiter { + /// The operator's `PluginConfig` as received. Kept around + /// because the `Plugin::config()` trait method returns + /// `&PluginConfig` โ€” the executor needs it for capability + /// gating, on_error policy, etc. + cfg: PluginConfig, + /// Cached typed view of `cfg.config`. Built once in `new()`. + parsed: ParsedConfig, + // ... any other runtime state: counters, expiry trackers, etc. +} + +impl MyRateLimiter { + /// Single constructor entry point. Follows the framework + /// convention: plugin takes ONLY `PluginConfig` and derives + /// all internal state from `cfg.config`. Operators never pass + /// pre-built typed pieces; everything flows through the + /// unified config pipeline. + fn new(cfg: PluginConfig) -> Result { + let raw = cfg + .config + .as_ref() + .ok_or_else(|| "rate-limit plugin requires a `config:` block".to_string())?; + // Deserialize cfg.config into the typed view. This is the + // ONLY config materialization โ€” operators don't supply + // settings any other way. + let parsed: ParsedConfig = serde_json::from_value(raw.clone()) + .map_err(|e| format!("invalid rate-limit config: {e}"))?; + Ok(Self { cfg, parsed }) + } +} + +#[async_trait] +impl Plugin for MyRateLimiter { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for MyRateLimiter { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // self.parsed.max_per_second is the same value the operator + // wrote at `config.max_per_second` in YAML โ€” just typed + // and cached. No JSON parsing on the hot path. + let _budget = self.parsed.max_per_second; + // ... rate-limit logic ... + PluginResult::allow() + } +} + +// The macro generates the `#[no_mangle] pub unsafe extern "C" fn +// cpex_plugin_create(...)` entry point. ABI handshake, config +// parsing, catch_unwind, and ownership transfer of the +// PluginRegistration are all handled inside the macro expansion. +cpex_dynamic_plugin! { + |cfg: PluginConfig| -> Result { + let plugin = Arc::new(MyRateLimiter::new(cfg)?); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "my-rate-limiter", + env!("CARGO_PKG_VERSION"), + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) + } +} +``` + +### 4. Build + +```sh +cargo build --release +``` + +Output lands at `target/release/libmy_rate_limiter.{so,dylib,dll}` +(Cargo converts hyphens to underscores in the artifact filename; +`lib` prefix appears on Unix, not Windows). + +### 5. Use it + +The operator references the plugin in unified-config YAML by its +absolute path: + +```yaml +plugins: + - name: rate-limit # operator's name for the plugin + kind: "lib:/opt/plugins/libmy_rate_limiter.so" + hooks: [cmf.tool_pre_invoke] + capabilities: [read_headers] + config: + max_per_second: 200 # โ† the plugin reads these from cfg.config + burst: 50 # โ† (deserialized once into ParsedConfig) +``` + +The host wires the factory once at startup: + +```rust +mgr.register_factory_scheme( + "lib", + Box::new(cpex_dynamic_plugin::DynamicPluginFactory::new()), +); +mgr.load_config_file(Path::new("plugins.yaml"))?; +mgr.initialize().await?; +``` + +--- + +## Names and identifiers + +Two `name` fields show up around a dynamic plugin, and they +serve different purposes. They can be the same string if you +want โ€” but they don't have to be. + +| Where | Set by | Used for | +|---|---|---| +| YAML `plugins[i].name:` (โ†’ `PluginConfig.name`) | **Operator** | Operational identifier. Hook registration keys, per-plugin context state, error messages (`"plugin 'rate-limit' denied: ..."`), audit logs. The framework treats this as authoritative. | +| `PluginRegistration::new(name, ...)` (โ†’ `PluginRegistration.name`) | **Plugin author** | Diagnostic-only self-report. Surfaces in the loader's `tracing::info!` line as `plugin_reported_name = "..."` so operators can sanity-check that the cdylib they loaded is the one they expected. The framework doesn't route on this. | + +In Quick Start ยง3 / ยง5 the operator writes `name: rate-limit` in +YAML while the plugin author writes `"my-rate-limiter"` in +`PluginRegistration::new`. Both are fine โ€” they're different +identifiers serving different concerns. Setting them to the same +string is also fine; many operators do exactly that for clarity. + +**Two scenarios where keeping them distinct is useful:** + +1. **Multiple operator-instances of the same plugin code.** An + operator can load the same cdylib twice with different + settings, each under its own operator name: + + ```yaml + plugins: + - name: rate-limit-api # operator's name #1 + kind: "lib:/opt/plugins/libmy_rate_limiter.so" + config: { max_per_second: 200 } + + - name: rate-limit-admin # operator's name #2 + kind: "lib:/opt/plugins/libmy_rate_limiter.so" + config: { max_per_second: 10 } + ``` + + Both load the same cdylib, both report + `plugin_reported_name = "my-rate-limiter"`, but the + operational identifiers stay distinct. Audit logs and + per-plugin context state correctly attribute work to the + right instance. + +2. **Sanity-check at load time.** If the wrong `.so` got dropped + into the plugins directory, the operator's name says + "innocent-rate-limit" but the load log surfaces + `plugin_reported_name = "evil-keylogger"`. Mismatch between + the operator's expectation and the plugin's self-report is + visible without grepping through binaries. + +If those don't apply to you, just use the same string in both +places. + +--- + +## Plugin construction convention + +Plugins follow a single rule: **the constructor takes only +`PluginConfig`.** All runtime state is derived from `cfg.config` +inside `new()`. No alternate constructors that accept already- +built typed pieces. + +**Why:** consistent instantiation via the unified-config pipeline. +The operator writes one YAML block; the host's factory +deserializes the `PluginConfig`; the plugin's `new()` extracts +and validates the typed config. Tests follow the same path โ€” +construct a `PluginConfig` with the right `config:` value and +exercise `new()` like production code does. This catches +config-parsing regressions automatically. + +**Don't do this:** +```rust +// โœ— separate typed parameters bypass the config-driven path +MyRateLimiter::new(cfg, max_per_second, claim_mapper) +``` + +**Do this:** +```rust +// โœ“ everything flows through cfg.config +MyRateLimiter::new(cfg) +``` + +--- + +## Multiple handlers per plugin + +A single plugin crate can register more than one handler. There +are two common patterns: + +### Pattern A โ€” one struct, multiple hook names (same `HookTypeDef`) + +Most common for plugins that participate in multiple CMF phases +(pre + post, args + result). The same struct implements +`HookHandler` once and is wired under multiple hook +names: + +```rust +let plugin = Arc::new(MyPlugin::new(cfg)?); + +let pre_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), +); +let post_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), +); + +Ok(PluginRegistration::new( + "my-plugin", + env!("CARGO_PKG_VERSION"), + plugin as Arc, + vec![ + ("cmf.tool_pre_invoke".to_string(), pre_adapter), + ("cmf.tool_post_invoke".to_string(), post_adapter), + ], +)) +``` + +The operator's `hooks:` array in YAML lists which of the +registered hooks should actually fire for that plugin instance. + +### Pattern B โ€” multiple structs / multiple `HookTypeDef`s + +If the plugin does conceptually different things at different +hooks (e.g., identity resolution AND CMF policy), wire each +behavior as its own struct + adapter. Each sub-struct still gets +the full `PluginConfig` and derives its own state from it: + +```rust +let identity = Arc::new(MyIdentityResolver::new(cfg.clone())?); +let policy = Arc::new(MyPolicyGate::new(cfg.clone())?); + +let id_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&identity)), +); +let policy_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&policy)), +); + +// Pick which one becomes the plugin's "primary" representation โ€” +// usually the higher-level / authoritative one. PluginRegistration +// only carries a single Plugin handle; for plugins with multiple +// distinct components, use whichever you'd want surfaced in +// diagnostics. +Ok(PluginRegistration::new( + "auth-bundle", + env!("CARGO_PKG_VERSION"), + identity as Arc, + vec![ + ("identity.resolve".to_string(), id_adapter), + ("cmf.tool_pre_invoke".to_string(), policy_adapter), + ], +)) +``` + +### Selecting a specific handler from YAML + +When a single cdylib registers multiple handlers but the operator +only wants one of them active for a given plugin entry, add a +fragment to the `kind:` string: + +```yaml +plugins: + # Same cdylib, two YAML entries, two handlers selected. + - name: id + kind: "lib:/opt/plugins/libauth_bundle.so#identity.resolve" + hooks: [identity.resolve] + - name: policy + kind: "lib:/opt/plugins/libauth_bundle.so#cmf.tool_pre_invoke" + hooks: [cmf.tool_pre_invoke] +``` + +The `#` fragment names the hook the operator wants kept; all +other handlers from the registration are filtered out. Without a +fragment, every registered handler is wired. + +--- + +## Multiple plugins per cdylib + +The `cpex_dynamic_plugin!` macro (singular) emits one plugin per +shared library. If you want to ship several unrelated plugins in +one binary โ€” different code, different identities, different +versions โ€” use the `cpex_dynamic_plugins!` macro (plural) instead. + +### Why pick this over multi-handler? + +The two shapes solve different problems: + +| Shape | Macro | One PluginRegistration per | When | +|-------|-------|---------------------------|------| +| **Multi-handler** | `cpex_dynamic_plugin!` | cdylib (with many `(hook, handler)` pairs inside) | One plugin that participates in several lifecycle hooks. | +| **Multi-plugin** | `cpex_dynamic_plugins!` | `?entry=` selector | Several genuinely distinct plugins packaged together for deployment convenience. | + +Use multi-handler unless you specifically need multiple +*independent* plugins. The two shapes are not mutually exclusive โ€” +each entry in a multi-plugin cdylib can itself register multiple +handlers. + +### Plugin author side + +```rust +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_dynamic_plugin::{cpex_dynamic_plugins, PluginRegistration}; + +fn build_rate_limiter(cfg: PluginConfig) -> Result { + // ... build and return PluginRegistration ... +# unimplemented!() +} + +fn build_audit(cfg: PluginConfig) -> Result { + // ... build and return PluginRegistration ... +# unimplemented!() +} + +cpex_dynamic_plugins! { + rate_limiter => { + name: "Rate Limiter", + version: "1.0.0", + description: "Token-bucket rate limiter", + create: build_rate_limiter, + }, + audit => { + name: "Audit Logger", + version: "0.5.0", + description: "Writes hook events to disk", + create: build_audit, + }, +} +``` + +The macro generates: + +* One `cpex_plugin_create_` symbol per entry (the ident + before `=>`). The host resolves these by composing the entry + name from the operator's `?entry=` URL. +* A `cpex_plugin_list` discovery symbol. Hosts read it to validate + the operator's `?entry=` against the available entries up-front, + so unknown entries get a friendly "available: [rate_limiter, + audit]" error instead of a raw "symbol not found" from dlsym. + +The entry name (`rate_limiter`, `audit`) MUST be a valid Rust +identifier โ€” the macro requires that. It also has to be a valid C +identifier so the generated symbol name is well-formed; the host +validates the operator's `?entry=` against +`[a-zA-Z_][a-zA-Z0-9_]*` before any symbol lookup. + +### Operator side + +Each entry is addressable from YAML via `?entry=`: + +```yaml +plugins: + - name: edge-rate-limit + kind: "lib:/opt/plugins/libmulti.so?entry=rate_limiter" + hooks: [cmf.tool_pre_invoke] + config: + max_per_second: 100 + - name: audit-trail + kind: "lib:/opt/plugins/libmulti.so?entry=audit" + hooks: [cmf.tool_post_invoke] + config: + log_path: /var/log/cpex-audit.log +``` + +The shared library is `dlopen`'d once (the OS dedupes), but each +entry produces an independent `PluginInstance` with its own +config, name, and handler set. + +URL component order is `:[?entry=][#handler]`, +so `?entry=` and `#handler` can be combined for a multi-plugin +cdylib whose entries themselves register multiple handlers: + +```yaml +kind: "lib:/opt/plugins/libmulti.so?entry=audit#cmf.tool_post_invoke" +``` + +### Single-plugin migration is opt-in + +Cdylibs built with the singular `cpex_dynamic_plugin!` keep +working exactly as before โ€” they export `cpex_plugin_create` with +no entry suffix, no manifest, and YAML keeps using +`kind: "lib:/path/foo.so"` with no `?entry=`. Nothing changes +unless you migrate to `cpex_dynamic_plugins!`. The two macros are +independent; pick one per cdylib based on whether you're shipping +one plugin or several. + +--- + +## Plugin configuration + +Plugins read their settings from `cfg.config` โ€” the +`Option` field on `PluginConfig`. Operators +populate it from the unified-config YAML's `config:` block: + +```yaml +plugins: + - name: rate-limit + kind: "lib:/opt/plugins/librate_limit.so" + config: # โ† this is cfg.config + max_per_second: 200 + burst: 50 + whitelist: ["10.0.0.0/8"] +``` + +There is only ever one place a plugin's settings live: `cfg.config`. +The pattern shown in Quick Start ยง3 (define `ParsedConfig`, +deserialize once in `new()`, store the typed view on `self`) is a +performance/ergonomics optimization โ€” `ParsedConfig` is a *cached +typed view* of `cfg.config`, not a separate config channel. +Initialization errors (missing required fields, unparseable +values, etc.) โ€” return `Err(String)` from `new()`. The +`cpex_dynamic_plugin!` macro propagates that into +`EntryPointResult::InitializationError`, which the host surfaces +via `PluginError::Config` with the cdylib path included in the +diagnostic. + +--- + +## What does NOT go in `config:` + +The operator's `kind:` string is the right place for loader +concerns, not the plugin's `config:` block. Specifically: + +| Loader concern | Goes in `kind:` | +|---|---| +| Library path | `lib:/opt/plugins/foo.so` | +| Handler filter | `...#cmf.tool_pre_invoke` | + +The reason: `config:` is plugin-specific config the plugin's own +typed view deserializes from. Mixing loader fields into it would +force plugins to know about the loader's reserved keys, and +operators would lose the natural separation between "where does +this plugin come from" (a deployment concern) and "how does this +plugin behave" (a runtime concern). + +--- + +## ABI versioning and the same-version constraint + +The Rust ABI is unstable across compiler versions and across +patch versions of dependencies. **The plugin's cdylib and the +host MUST be compiled against the same versions of:** + + * `cpex-core` + * `cpex-dynamic-plugin` + * The Rust compiler (`rustc --version`) + +Mismatches are checked at load time via the +`cpex_dynamic_plugin::ABI_VERSION` constant. When the host's +`DynamicPluginFactory` calls into the plugin's entry point, the +plugin compares the host's reported `ABI_VERSION` to its own +compiled-against value and returns `EntryPointResult::AbiMismatch` +on disagreement. The host surfaces this as a `PluginError::Config` +with the actionable text: *"Rebuild the plugin against the same +cpex-core / cpex-dynamic-plugin versions the host is using."* + +`ABI_VERSION` is bumped on any breaking change to: + + * the entry-point function signature + * `PluginRegistration` field layout + * `AnyHookHandler` trait shape + * `Extensions` / `MessagePayload` layout + +In practice this means a plugin built against `cpex-core 0.2.0` +won't load into a host running `cpex-core 0.3.0`. Operators +should rebuild plugins whenever they upgrade the host. + +### Why no abi_stable + +We considered `abi_stable` for true cross-version compatibility. +It adds significant surface (every trait needs `#[sabi_trait]` +wrappers, every type needs `StableAbi` derives) and changes the +plugin-author API. Same-version-only is the simpler default; we +can revisit if multi-vendor plugin marketplaces become a real +need. + +--- + +## Error diagnostics + +When a plugin fails to load, the host reports a `PluginError::Config` +with a human-readable message embedding the failure mode. Common +ones: + +| Symptom | Cause | Fix | +|---|---|---| +| `failed to dlopen ''` | File doesn't exist, wrong permissions, or wrong arch (e.g., x86_64 plugin on arm64 host). | Verify path, file mode, and `lipo -info `. | +| `cdylib does not export 'cpex_plugin_create'` | Plugin doesn't use the `cpex_dynamic_plugin!` macro, or it's declared without `#[no_mangle] pub extern "C"`. | Use the macro; don't write the entry point by hand. | +| `cdylib was compiled against a different cpex-dynamic-plugin ABI version` | Plugin built against a different `cpex-core` / `cpex-dynamic-plugin` version than the host. | Rebuild the plugin against the host's exact dep versions. | +| `cdylib rejected its PluginConfig` | Operator's YAML has a structural mismatch with the plugin's expected config schema. | Check the plugin's documented config schema. | +| `cdylib failed to initialize` | Plugin's `new()` returned `Err(_)` (config validation, key load, network probe, etc.). | Check the cdylib's logs / stderr for the underlying error. | +| `cdylib panicked during construction` | Plugin code unwound inside `new()` or the macro closure. Caught at the FFI boundary, didn't crash the host. | Check the cdylib's logs / stderr for the panic backtrace. | +| `returned no handler named ''` | The `#` fragment in the kind selected a handler that the plugin didn't register. | Check the cdylib's documentation for which handler names it exposes, or omit the fragment to take all handlers. | + +All errors include the operator-supplied plugin `name` and the +absolute library path for ops debugging. + +--- + +## Limitations and trade-offs + +* **Load-at-startup only.** No hot reload. Loaded libraries are + leaked (`Box::leak`) and stay mapped until process exit. This + is the standard Rust plugin-loader pattern (Bevy and others + follow it). Hot reload requires reference-counting the library + alongside all derived `Arc` / `Arc` references โ€” out of scope for v0. +* **No sandbox.** Loaded plugins run in-process with full host + privileges (file system, network, memory, syscalls). Operators + vet plugins before deploying them. Capability gating still + applies to extension access (just like in-tree plugins), but + it does not stop a malicious plugin from making arbitrary + syscalls. +* **Allocator.** Plugin and host must share an allocator. Both + use `std::alloc::System` by default โ€” don't override the + allocator in your plugin (`#[global_allocator]`) unless the + host uses the same one. +* **No nested `block_on`.** Async handlers must not `block_on` + inside `handle()` โ€” the future is already running on a tokio + task, and nested blocking will panic. Same rule as in-tree + plugins, but easier to forget when the plugin lives in another + repo. +* **Same-version-only** (see "ABI versioning" above). Rebuild + plugins on host upgrades. + +--- + +## Reference examples + +All under [`examples/`](./examples), each a standalone cdylib +crate built alongside this crate's integration tests: + +* **[`single-plugin/`](./examples/single-plugin)** โ€” minimal + allow-everything plugin. The simplest possible `cpex_dynamic_plugin!` + (singular) shape; ~50 lines. Start here. +* **[`multi-handler/`](./examples/multi-handler)** โ€” one plugin + with two handlers wired to different hooks (pre-invoke allow, + post-invoke deny). Demonstrates Pattern A from the multi-handler + section above. +* **[`multi-plugin/`](./examples/multi-plugin)** โ€” two distinct + plugins (allow + deny) packaged in one cdylib via + `cpex_dynamic_plugins!` (plural). Operator selects via + `?entry=allow` or `?entry=deny`. + +## Tests + +* `cargo test -p cpex-dynamic-plugin` โ€” unit tests for the + plugin-side ABI helpers and the kind-string parser (no + dlopen involved). +* `cargo test -p cpex-dynamic-plugin --features host` โ€” full + suite including the dlopen integration tests that load every + reference example above at runtime. diff --git a/crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml b/crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml new file mode 100644 index 00000000..b6fee876 --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml @@ -0,0 +1,34 @@ +# Location: ./crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Reference cdylib plugin that registers TWO handlers with +# distinguishable behaviors: +# +# * `cmf.tool_pre_invoke` โ†’ returns `PluginResult::allow()` +# * `cmf.tool_post_invoke` โ†’ returns `PluginResult::deny(...)` +# +# Used by `cpex-dynamic-plugin`'s integration tests to verify: +# * The `#handler` fragment in the kind string correctly filters +# a multi-handler registration to just the named handler. +# * Multiple dynamic plugins can coexist in one PluginManager +# (paired with the allow-gate `cpex-dynamic-plugin-example`). +# +# Pattern A from the README โ€” same plugin struct, multiple +# adapters wired under different hook names. + +[package] +name = "cpex-dynamic-plugin-multi-handler-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cpex-core = { path = "../../../cpex-core" } +cpex-dynamic-plugin = { path = "../.." } +async-trait = { workspace = true } diff --git a/crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs b/crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs new file mode 100644 index 00000000..832c8b55 --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs @@ -0,0 +1,114 @@ +// Location: ./crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Multi-handler reference plugin. Registers two CmfHook handlers +// with intentionally different verdicts so tests can distinguish +// "which handler fired" from the pipeline outcome alone: +// +// * `cmf.tool_pre_invoke` โ†’ AllowHandler โ†’ continue_processing = true +// * `cmf.tool_post_invoke` โ†’ DenyHandler โ†’ continue_processing = false, +// violation.code = "test.multi_handler.post_deny" +// +// Pattern A from the README: one Plugin instance, two adapters +// over different `HookHandler` impls. Lets the integration +// tests verify the `#handler` fragment filter works against a +// real multi-handler cdylib. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginViolation; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; + +/// Pre-invoke handler โ€” always allows. +struct AllowOnPre { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowOnPre { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowOnPre { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +/// Post-invoke handler โ€” always denies with a distinctive code so +/// tests can identify which handler fired by inspecting the +/// violation. +struct DenyOnPost { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyOnPost { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyOnPost { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "test.multi_handler.post_deny", + "deny-on-post handler fired", + )) + } +} + +cpex_dynamic_plugin! { + |cfg: PluginConfig| -> Result { + // Two distinct plugin structs, one Arc each. The plugin + // exposed via PluginRegistration is the AllowOnPre โ€” the + // post handler is technically a separate Plugin instance, + // but the registration only carries one "primary" Plugin + // handle for diagnostic purposes. Functionally, both + // handlers run independently when their respective hooks + // fire. + let allow = Arc::new(AllowOnPre { cfg: cfg.clone() }); + let deny = Arc::new(DenyOnPost { cfg: cfg.clone() }); + + let allow_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&allow)), + ); + let deny_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&deny)), + ); + + Ok(PluginRegistration::new( + "cpex-dynamic-plugin-multi-handler-example", + env!("CARGO_PKG_VERSION"), + allow as Arc, + vec![ + ("cmf.tool_pre_invoke".to_string(), allow_adapter), + ("cmf.tool_post_invoke".to_string(), deny_adapter), + ], + )) + } +} diff --git a/crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml b/crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml new file mode 100644 index 00000000..86229912 --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml @@ -0,0 +1,41 @@ +# Location: ./crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Reference cdylib that packages MULTIPLE distinct plugins in one +# shared library. Uses the `cpex_dynamic_plugins!` (plural) macro +# to emit: +# +# * cpex_plugin_create_allow โ€” always-allow gate. +# * cpex_plugin_create_deny โ€” always-deny gate with a +# recognizable violation code. +# * cpex_plugin_list โ€” manifest discovery symbol. +# +# Operator selects which one to load via `?entry=` in the +# kind URL: +# +# kind: "lib:/path/multi_plugin.so?entry=allow" +# kind: "lib:/path/multi_plugin.so?entry=deny" +# +# Used by `cpex-dynamic-plugin`'s integration tests to verify: +# * Multiple plugins coexist in one cdylib. +# * The host's symbol resolution + manifest validation works. +# * Friendly errors for unknown entries. +# * Default `cpex_plugin_create` symbol absence doesn't break +# anything (operator MUST specify `?entry=` for this cdylib). + +[package] +name = "cpex-dynamic-plugin-multi-plugin-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cpex-core = { path = "../../../cpex-core" } +cpex-dynamic-plugin = { path = "../.." } +async-trait = { workspace = true } diff --git a/crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs b/crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs new file mode 100644 index 00000000..3bbc6aba --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs @@ -0,0 +1,138 @@ +// Location: ./crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Multi-plugin reference cdylib. Packages two truly distinct +// plugins (different structs, different behaviors, different +// versions) inside one shared library, addressable via the +// operator's `?entry=` URL parameter. +// +// This complements `cpex-dynamic-plugin-multi-handler-example`, +// which has ONE plugin registering MULTIPLE handlers. The two +// shapes are independent: +// +// * Multi-handler (cpex_dynamic_plugin! singular): one plugin +// hooks several lifecycle points. One entry point, several +// `(hook_name, handler)` pairs. +// * Multi-plugin (cpex_dynamic_plugins! plural): several +// unrelated plugins shipped in one binary for deployment +// convenience. Several entry points, one PluginRegistration +// per call. +// +// Tests use the verdict + violation code to identify which +// entry-point function the host actually called. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginViolation; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugins, PluginRegistration}; + +// ----- Plugin 1: Allow gate ----- + +struct AllowGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +fn build_allow(cfg: PluginConfig) -> Result { + let plugin = Arc::new(AllowGate { cfg }); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "allow-gate", + "1.0.0", + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) +} + +// ----- Plugin 2: Deny gate ----- + +struct DenyGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "test.multi_plugin.deny", + "deny-gate plugin fired", + )) + } +} + +fn build_deny(cfg: PluginConfig) -> Result { + let plugin = Arc::new(DenyGate { cfg }); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "deny-gate", + "0.5.0", + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) +} + +// ----- Multi-plugin registration ----- +// +// Generates `cpex_plugin_create_allow`, `cpex_plugin_create_deny`, +// and `cpex_plugin_list`. Note: this cdylib does NOT expose the +// default `cpex_plugin_create` symbol โ€” operators MUST use +// `?entry=`. That's deliberate: if you're packaging multiple +// plugins, there's no sensible default. +cpex_dynamic_plugins! { + allow => { + name: "Allow Gate", + version: "1.0.0", + description: "Always allows; useful for smoke-testing the pipeline", + create: build_allow, + }, + deny => { + name: "Deny Gate", + version: "0.5.0", + description: "Always denies with code test.multi_plugin.deny", + create: build_deny, + }, +} diff --git a/crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml b/crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml new file mode 100644 index 00000000..d49cbb7c --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml @@ -0,0 +1,49 @@ +# Location: ./crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Reference dynamic plugin โ€” compiles as a cdylib loaded at runtime +# by `cpex-dynamic-plugin`'s `DynamicPluginFactory`. Used by +# `cpex-dynamic-plugin`'s integration tests as the load-bearing +# "everything actually works" check. +# +# # Crate types +# +# * `cdylib` โ€” what the host actually loads via `libloading`. +# Produces `libcpex_dynamic_plugin_example.{so,dylib,dll}` +# in the workspace target dir. +# * `rlib` โ€” lets other workspace crates (specifically +# `cpex-dynamic-plugin`'s dev-dep on this) trigger +# cargo to build the cdylib as a side effect of +# running tests. Without the rlib, cargo can't +# depend on the cdylib in the Rust sense. +# +# # What this plugin does +# +# Trivial CMF plugin that always allows. Confirms end-to-end: +# 1. `cpex_dynamic_plugin!` macro generates the entry point. +# 2. Host's `DynamicPluginFactory` dlopens + binds + calls it. +# 3. The returned `Arc` + handler survive across the +# FFI boundary intact and the executor can invoke them. +# +# Doesn't try to test the full surface (config-driven behavior, +# violations, payload mutation) โ€” that's what later, deployment- +# specific plugins would exercise. + +[package] +name = "cpex-dynamic-plugin-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +# `cdylib` is what dlopens; `rlib` is the rustc-level handle other +# workspace crates can depend on to trigger building both. +crate-type = ["cdylib", "rlib"] + +[dependencies] +cpex-core = { path = "../../../cpex-core" } +cpex-dynamic-plugin = { path = "../.." } +async-trait = { workspace = true } diff --git a/crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs b/crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs new file mode 100644 index 00000000..dd457efe --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs @@ -0,0 +1,68 @@ +// Location: ./crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Reference cdylib plugin โ€” the bare-minimum shape a dynamic plugin +// takes. Used as the integration-test fixture for +// `cpex-dynamic-plugin`. Plugin authors write code that looks +// essentially like this. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; + +/// Minimal allow-everything plugin. Real plugins do more, but the +/// goal here is to prove the load + invoke path through the dlopen +/// boundary works. +struct AllowGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +// The macro generates the `#[no_mangle] pub unsafe extern "C" fn +// cpex_plugin_create(...)` entry point. Plugin author writes a +// closure that builds the registration; macro handles all the +// FFI safety glue (abi-check, config parse, catch_unwind, raw +// pointer ownership transfer). +cpex_dynamic_plugin! { + |cfg: PluginConfig| -> Result { + let plugin = Arc::new(AllowGate { cfg }); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "cpex-dynamic-plugin-example", + env!("CARGO_PKG_VERSION"), + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) + } +} diff --git a/crates/cpex-dynamic-plugin/src/abi.rs b/crates/cpex-dynamic-plugin/src/abi.rs new file mode 100644 index 00000000..c070f310 --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/abi.rs @@ -0,0 +1,222 @@ +// Location: ./crates/cpex-dynamic-plugin/src/abi.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Shared ABI types between plugin (cdylib) and host (loader). +// +// # Layout-stable contract +// +// Plugin and host MUST be compiled against the same `cpex-core` +// version. Rust's `repr(Rust)` types don't have a stable layout +// across compiler versions, so even patch-version bumps to +// `cpex-core` invalidate the contract. The `ABI_VERSION` constant +// below is bumped on every change to: +// +// * `PluginRegistration` field layout (added/removed/reordered) +// * `EntryPointResult` discriminants +// * `cpex_core::registry::AnyHookHandler` trait shape (methods, +// order, signatures) +// * `cpex_core::hooks::payload` types (`Extensions`, `MessagePayload`) +// +// Plugin's entry point reports its compiled-against ABI_VERSION; +// host rejects load if mismatched. Same-version-only is the +// load-bearing constraint; the runtime check makes mismatches +// loud instead of UB. +// +// # The entry-point contract +// +// Each plugin cdylib exports a single C function named +// `cpex_plugin_create` with the [`EntryPointFn`] signature. The +// plugin-author macro [`crate::cpex_dynamic_plugin!`] generates +// this function so authors don't write unsafe FFI by hand. +// +// Ownership: the plugin allocates the `PluginRegistration` via +// `Box::new(...)` + `Box::into_raw(...)`, writes the pointer to +// `out_registration`. Host takes ownership via `Box::from_raw(...)`. +// Same default allocator on both sides (`std::alloc::System`) means +// the host can drop the box safely. + +use std::sync::Arc; + +use cpex_core::plugin::Plugin; +use cpex_core::registry::AnyHookHandler; + +/// Bumped on any breaking change to the ABI surface (see module +/// docs). Plugin and host must report identical values or the load +/// is rejected. +pub const ABI_VERSION: u32 = 1; + +/// Status code the plugin's entry point returns to the host. +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryPointResult { + /// Plugin constructed successfully. `out_registration` is + /// populated; host takes ownership of the boxed registration. + Ok = 0, + /// Plugin's `ABI_VERSION` didn't match the host's. The + /// plugin did NOT touch `out_registration`; host should not + /// read it. + AbiMismatch = 1, + /// Plugin couldn't parse its serialized `PluginConfig`. + ConfigParseError = 2, + /// Plugin's own initialization failed (key load, network + /// probe, missing required claim, etc.). + InitializationError = 3, + /// Plugin's entry-point body panicked. Caught at the FFI + /// boundary so the host doesn't get an unwinding panic + /// across `extern "C"` (which is UB). + Panic = 4, +} + +/// The plugin's entry-point function signature. Plugins export +/// this as `cpex_plugin_create`; host's loader uses +/// `libloading::Symbol` to bind to it. +/// +/// Arguments: +/// +/// * `abi_version` โ€” value the *host* was compiled against. The +/// plugin compares this to its own [`ABI_VERSION`] and returns +/// [`EntryPointResult::AbiMismatch`] on a mismatch. +/// * `plugin_config_json` / `plugin_config_len` โ€” serialized +/// `PluginConfig` (the operator's YAML block, JSON-encoded). +/// Plugin deserializes; uses it to construct its handlers. +/// * `out_registration` โ€” out-parameter. On `Ok`, plugin writes +/// a `Box::into_raw(Box::new(PluginRegistration { ... }))` +/// pointer. On any error variant, plugin leaves this untouched. +pub type EntryPointFn = unsafe extern "C" fn( + abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut PluginRegistration, +) -> EntryPointResult; + +/// The symbol name a single-plugin cdylib exports. Host looks +/// this up via `libloading::Library::get(ENTRY_POINT_SYMBOL)`. +/// +/// Multi-plugin cdylibs use the `cpex_plugin_create_` +/// naming convention instead; the operator selects an entry via +/// the `?entry=` query parameter in the kind URL. +pub const ENTRY_POINT_SYMBOL: &[u8] = b"cpex_plugin_create"; + +/// Symbol name for the OPTIONAL multi-plugin discovery function. +/// +/// A cdylib that packages multiple plugins MAY export this symbol +/// to advertise which entries are available. Single-plugin cdylibs +/// don't need to expose it; the host falls back to plain dlsym +/// errors when the manifest is absent. +/// +/// See [`ListFn`] and [`PluginManifest`]. +pub const LIST_SYMBOL: &[u8] = b"cpex_plugin_list"; + +/// Signature of the optional discovery function exported as +/// [`LIST_SYMBOL`]. Returns a pointer to a `'static` +/// [`PluginManifest`] baked into the cdylib's read-only data. +/// +/// # Returned pointer +/// +/// The pointer is to static data that lives as long as the cdylib +/// is mapped. Since `DynamicPluginFactory` leaks the `Library` +/// handle (so vtables don't dangle), the manifest is effectively +/// `'static` from the host's perspective. The host never frees it +/// and the plugin never reallocates it โ€” it's a compile-time +/// constant. +/// +/// A null return value means "no manifest available" and is +/// equivalent to the symbol being absent. +pub type ListFn = unsafe extern "C" fn() -> *const PluginManifest; + +/// One entry in a cdylib's plugin manifest. All fields are +/// `&'static str` because the data is baked into the cdylib's +/// read-only memory at compile time โ€” nothing for the host to +/// free, nothing for the plugin to reallocate. +/// +/// # ABI shape +/// +/// `&'static str` is a fat pointer (data + length) with same- +/// version Rust layout. Because the whole `cpex-dynamic-plugin` +/// ABI is already same-version-only (the `AnyHookHandler` vtable +/// has the same constraint), reusing Rust slices here doesn't +/// add new ABI assumptions. Marking `repr(C)` would be a lie โ€” +/// fat pointers aren't C-shaped. +#[derive(Debug, Clone, Copy)] +pub struct PluginManifestEntry { + /// The entry-point suffix. Full exported symbol is + /// `cpex_plugin_create_`. Goes in the kind URL's + /// `?entry=` selector. MUST be a valid C identifier + /// (`[a-zA-Z_][a-zA-Z0-9_]*`); the host rejects entries that + /// violate this on the parse side before any symbol lookup. + pub entry: &'static str, + /// Human-readable display name (used by the discovery + /// tooling, NOT used for symbol resolution). + pub name: &'static str, + /// Plugin version, conventionally `env!("CARGO_PKG_VERSION")`. + pub version: &'static str, + /// One-line description for the discovery tooling. + pub description: &'static str, +} + +/// What [`ListFn`] returns a pointer to. Wrapper around the +/// manifest's `'static` entry slice plus an ABI-version tag. +/// +/// The `abi_version` field lets the host detect manifest-layout +/// drift within the same major ABI. For hard-breaking changes +/// to the manifest shape, bump the symbol name itself (e.g., +/// `cpex_plugin_list_v2`) rather than relying on the version +/// field alone. +pub struct PluginManifest { + /// ABI version this manifest was produced against. Host + /// rejects manifests whose `abi_version` doesn't match its + /// own [`ABI_VERSION`]. + pub abi_version: u32, + /// The cdylib's advertised plugin entries. + pub entries: &'static [PluginManifestEntry], +} + +/// What the plugin hands the host through `out_registration`. +/// +/// The plugin allocates this with `Box::new(...)` and transfers +/// ownership to the host. Host drops it after extracting the +/// `plugin` + `handlers` into a `PluginInstance`. +/// +/// `#[repr(Rust)]` โ€” same-version Rust ABI applies. Both sides +/// must see identical layout, which they will when compiled +/// against the same `cpex-core` version. +pub struct PluginRegistration { + /// ABI version this plugin reports. The host's loader has + /// already checked the version through the entry-point's + /// return code, but the field is included for diagnostics + /// (plugin's view of its own ABI). + pub abi_version: u32, + /// Plugin's reported name. Surfaced in operator-facing + /// diagnostics ("plugin 'rate-limit' (version 0.3.1) loaded + /// from /opt/plugins/rate_limit.so"). + pub name: String, + /// Plugin's reported version (typically `CARGO_PKG_VERSION`). + pub version: String, + /// The plugin instance itself (shared with handlers). + pub plugin: Arc, + /// Type-erased handlers paired with their hook names. + /// Mirrors `cpex_core::factory::PluginInstance.handlers`. + pub handlers: Vec<(String, Arc)>, +} + +impl PluginRegistration { + /// Convenience constructor โ€” fills `abi_version` from the + /// compiled-against constant so plugin authors don't have to + /// remember to set it. + pub fn new( + name: impl Into, + version: impl Into, + plugin: Arc, + handlers: Vec<(String, Arc)>, + ) -> Self { + Self { + abi_version: ABI_VERSION, + name: name.into(), + version: version.into(), + plugin, + handlers, + } + } +} diff --git a/crates/cpex-dynamic-plugin/src/host.rs b/crates/cpex-dynamic-plugin/src/host.rs new file mode 100644 index 00000000..04e035e6 --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/host.rs @@ -0,0 +1,764 @@ +// Location: ./crates/cpex-dynamic-plugin/src/host.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Host-side: `DynamicPluginFactory` implements cpex-core's +// `PluginFactory` and is registered under a scheme (default `"lib"`) +// via `PluginManager::register_factory_scheme(...)`. Operators +// reference dynamic plugins with URL-shaped `kind:` strings: +// +// ```yaml +// plugins: +// - name: rate-limit +// kind: "lib:/opt/plugins/rate_limit.so#rate_limit_v1" +// hooks: [cmf.tool_pre_invoke] +// capabilities: [read_headers] +// config: +// max_per_second: 100 # plugin's OWN config; loader +// # concerns stay in `kind:` +// ``` +// +// # Flow +// +// 1. Parse `config.kind` as `:[#handler]`. +// 2. Validate `scheme` matches `self.scheme`. +// 3. `Library::new(path)` to dlopen, then `Box::leak` the Library +// so it survives until process exit (see "Why leak" below). +// 4. Bind to `ENTRY_POINT_SYMBOL`. +// 5. Serialize the `PluginConfig` to JSON. +// 6. Call the entry point with `ABI_VERSION` + config bytes + +// out-pointer. +// 7. Match on `EntryPointResult` โ†’ `PluginError::Config` for any +// error variant (with the variant name embedded for ops +// diagnostics). +// 8. On `Ok`: `Box::from_raw` the registration; extract +// `plugin` + `handlers`; optionally filter handlers to just +// the one named in the `#handler` fragment. +// 9. Build `PluginInstance`. +// +// # Why leak the library +// +// `Arc` and `Arc` hold vtable +// pointers into the cdylib's text section. If the library is +// unloaded (`Drop` on `Library` โ†’ `dlclose`) while ANY of those +// Arcs is still live, the next Arc operation jumps to unmapped +// memory and SIGSEGVs. The Arcs are cloned into the registry and +// can outlive any wrapper struct we'd hand them to, so the only +// safe path is to keep the library mapped for the process +// lifetime. +// +// Memory cost: each loaded cdylib's text section (typically a few +// hundred KB to a few MB) stays resident. Operators load plugins +// at startup and never unload โ€” same model as Bevy and most Rust +// plugin frameworks. Hot-reload would need a reference-counted +// library wrapper coordinated with all derived Arcs; that's its +// own slice. + +use std::sync::Arc; + +use libloading::Library; + +use cpex_core::error::PluginError; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::plugin::PluginConfig; + +use crate::abi::{ + EntryPointFn, EntryPointResult, ListFn, PluginManifest, PluginRegistration, ABI_VERSION, + ENTRY_POINT_SYMBOL, LIST_SYMBOL, +}; + +/// Loads Rust cdylib plugins at runtime. Registered under a scheme +/// (default `"lib"`) via `PluginManager::register_factory_scheme`. +/// Operators reference dynamic plugins with URL-shaped `kind:` +/// strings like `"lib:/path/to/plugin.so#handler"`. +pub struct DynamicPluginFactory { + scheme: String, +} + +impl DynamicPluginFactory { + /// Build with the default scheme `"lib"`. + pub fn new() -> Self { + Self { + scheme: "lib".to_string(), + } + } + + /// Override the default scheme. The factory must be registered + /// under the same scheme via `PluginManager::register_factory_scheme`. + pub fn with_scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } + + /// Returns the scheme this factory is configured for. + pub fn scheme(&self) -> &str { + &self.scheme + } +} + +impl Default for DynamicPluginFactory { + fn default() -> Self { + Self::new() + } +} + +/// Parsed kind: `:[?entry=][#handler]`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedKind { + /// Path to the cdylib file. + library_path: String, + /// Optional entry-point selector from the `?entry=` query + /// parameter. `None` means "use the default entry point + /// `cpex_plugin_create`" (single-plugin cdylib). `Some(name)` + /// means "look up `cpex_plugin_create_` instead" + /// (multi-plugin cdylib). + /// + /// Validated as a C identifier (`[a-zA-Z_][a-zA-Z0-9_]*`) in + /// `parse_kind` so we never construct a malformed symbol name. + entry: Option, + /// Optional handler name from the `#` fragment. `None` means + /// "use all handlers the registration returned." `Some(name)` + /// means "filter to just the handler registered under that + /// hook name in the registration." + handler: Option, +} + +/// Reject entry names that aren't valid C identifiers. Catches +/// malformed `?entry=` values at the URL-parse stage so we don't +/// construct invalid symbol names or pass weird bytes to dlsym. +/// +/// Accepts: starts with letter or underscore, followed by letters, +/// digits, or underscores. Same rule applies to the Rust ident +/// the macro accepts on the plugin side, so the two ends stay in +/// sync. +fn validate_entry_ident(entry: &str) -> Result<(), String> { + if entry.is_empty() { + return Err("entry name cannot be empty".to_string()); + } + let mut chars = entry.chars(); + let first = chars.next().expect("non-empty checked above"); + if !(first.is_ascii_alphabetic() || first == '_') { + return Err(format!( + "entry name '{entry}' must start with a letter or underscore (got '{first}')" + )); + } + for c in chars { + if !(c.is_ascii_alphanumeric() || c == '_') { + return Err(format!( + "entry name '{entry}' contains invalid character '{c}'; \ + only [a-zA-Z0-9_] allowed" + )); + } + } + Ok(()) +} + +/// Parse a `kind:` string into its scheme, path, optional entry, +/// and optional handler fragment. Returns an error message if the +/// shape is malformed (operator-facing diagnostic). +/// +/// URL component order: `:[?][#]`. +/// Currently the only recognized query parameter is `entry=`; +/// unknown parameters are rejected (fail-loud beats silently +/// ignoring operator typos). +/// +/// Examples: +/// - `lib:/opt/plugins/foo.so` โ†’ path only +/// - `lib:/opt/plugins/foo.so#bar` โ†’ path + handler "bar" +/// - `lib:/opt/plugins/foo.so?entry=baz` โ†’ path + entry "baz" +/// - `lib:/opt/plugins/multi.so?entry=baz#bar` โ†’ path + entry + handler +/// - `lib:/C:/plugins/foo.dll` โ†’ Windows path; preserved +/// - `lib:./relative.so` โ†’ relative path, +/// resolved by OS loader +fn parse_kind(kind: &str, expected_scheme: &str) -> Result { + let Some((scheme, rest)) = kind.split_once(':') else { + return Err(format!( + "kind '{kind}' missing scheme prefix; \ + expected '{expected_scheme}:[?entry=][#handler]'", + )); + }; + if scheme != expected_scheme { + return Err(format!( + "kind '{kind}' has scheme '{scheme}' but factory is registered for scheme '{expected_scheme}'", + )); + } + // Split the fragment off first (it comes last in URL order), + // then split query off the remaining (path + query) part. This + // ordering means a `?` inside a fragment (unusual but legal) + // stays in the fragment, and a `#` in the path is impossible + // because we'd already have consumed it as the fragment marker. + let (before_frag, handler) = match rest.split_once('#') { + Some((b, h)) => (b, Some(h.to_string())), + None => (rest, None), + }; + let (library_path, entry) = match before_frag.split_once('?') { + Some((path, query)) => { + let entry = parse_query_entry(kind, query)?; + (path.to_string(), entry) + } + None => (before_frag.to_string(), None), + }; + if library_path.is_empty() { + return Err(format!("kind '{kind}' has empty library path")); + } + if let Some(ref e) = entry { + validate_entry_ident(e).map_err(|why| format!("kind '{kind}': {why}"))?; + } + Ok(ParsedKind { + library_path, + entry, + handler, + }) +} + +/// Parse the query string of a kind URL. Only `entry=` is +/// recognized; multiple params would be ambiguous (which one wins?) +/// and unknown keys signal an operator typo we'd rather surface +/// than swallow. +fn parse_query_entry(kind: &str, query: &str) -> Result, String> { + if query.is_empty() { + return Ok(None); + } + // Reject `&` โ€” we don't support multi-param queries yet, and a + // bare `&` is almost certainly a copy-paste mistake. + if query.contains('&') { + return Err(format!( + "kind '{kind}' has multi-parameter query '{query}'; \ + only a single 'entry=' parameter is supported" + )); + } + let Some((key, value)) = query.split_once('=') else { + return Err(format!( + "kind '{kind}' has malformed query '{query}'; expected 'entry='" + )); + }; + if key != "entry" { + return Err(format!( + "kind '{kind}' has unknown query parameter '{key}'; \ + only 'entry=' is recognized" + )); + } + if value.is_empty() { + return Err(format!( + "kind '{kind}' has empty 'entry=' value; \ + provide an entry name like 'entry=my_plugin'" + )); + } + Ok(Some(value.to_string())) +} + +/// Try to read the cdylib's optional plugin manifest. Returns +/// `Ok(None)` when the cdylib doesn't export the discovery symbol +/// (legacy single-plugin layout) โ€” that's not an error. Returns +/// `Err` when the manifest IS present but its ABI version is wrong; +/// we shouldn't keep going in that case because the entries slice +/// could have a different layout than we expect. +/// +/// # Safety +/// +/// Caller guarantees `library` outlives any use of the returned +/// reference. In practice we leak the library in `create()`, so the +/// returned `'static` lifetime is honest: the manifest data lives +/// for the rest of the process. +unsafe fn read_manifest( + library: &'static Library, +) -> Result, String> { + let sym: libloading::Symbol<'_, ListFn> = match unsafe { library.get(LIST_SYMBOL) } { + Ok(s) => s, + Err(_) => return Ok(None), // No manifest exported โ€” that's fine. + }; + let ptr = unsafe { sym() }; + if ptr.is_null() { + // Plugin exposed the symbol but returned null โ€” treat as + // "no manifest" rather than an error. Plugin author can do + // this to disable discovery without removing the symbol. + return Ok(None); + } + let manifest: &'static PluginManifest = unsafe { &*ptr }; + if manifest.abi_version != ABI_VERSION { + return Err(format!( + "cdylib's plugin manifest reports ABI version {} but host expects {}", + manifest.abi_version, ABI_VERSION, + )); + } + Ok(Some(manifest)) +} + +impl PluginFactory for DynamicPluginFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + // 1. Parse the kind string. + let parsed = parse_kind(&config.kind, &self.scheme).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-dynamic-plugin): {}", + config.name, e + ), + }) + })?; + + // 2. dlopen + leak. After this point the library lives until + // process exit. `Box::leak` returns a &'static reference; + // we hold a raw pointer that we never reclaim. + // + // Safety: `Library::new` is unsafe because loading + // arbitrary code is inherently unsafe (the library could + // have init constructors that do anything). Operator + // chose the path; we trust them to know what they're + // loading. + let library: &'static Library = match unsafe { Library::new(&parsed.library_path) } + { + Ok(lib) => Box::leak(Box::new(lib)), + Err(e) => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': failed to dlopen '{}': {e}", + config.name, parsed.library_path, + ), + })); + } + }; + + // 3a. Read the optional plugin manifest. If the cdylib + // exposes one, we use it to: + // * Validate the operator's `?entry=` against the + // advertised entries. + // * Produce a "did you mean..." style error listing + // available entries when the operator gets it wrong. + // If the cdylib doesn't expose a manifest (single-plugin + // layout, or operator chose not to), we fall through to + // plain dlsym and surface its error verbatim. + let manifest = match unsafe { read_manifest(library) } { + Ok(m) => m, + Err(e) => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' has an incompatible manifest: {e}. \ + Rebuild the plugin against the same cpex-dynamic-plugin \ + version the host is using.", + config.name, parsed.library_path, + ), + })); + } + }; + + // 3b. If the operator specified `?entry=foo` AND the cdylib + // advertised a manifest, validate up-front that `foo` + // is in the manifest. This gives the friendliest error + // message ("available: [bar, baz]") before we even try + // dlsym. If the manifest is absent we just skip this + // check โ€” the dlsym below will fail with a less helpful + // but still actionable error. + if let (Some(requested), Some(m)) = (&parsed.entry, manifest) { + if !m.entries.iter().any(|e| e.entry == requested.as_str()) { + let available: Vec<&str> = + m.entries.iter().map(|e| e.entry).collect(); + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' has no entry '{}'. \ + Available entries: [{}]", + config.name, + parsed.library_path, + requested, + available.join(", "), + ), + })); + } + } + + // 3c. Build the symbol name. Default = `cpex_plugin_create` + // (single-plugin macro); with `?entry=foo` it becomes + // `cpex_plugin_create_foo` (multi-plugin macro). The + // entry name has already been validated as a C + // identifier in `parse_kind`, so we can safely concat + // bytes without escaping. + let symbol_name: Vec = match &parsed.entry { + None => ENTRY_POINT_SYMBOL.to_vec(), + Some(e) => { + let mut s = Vec::with_capacity(b"cpex_plugin_create_".len() + e.len()); + s.extend_from_slice(b"cpex_plugin_create_"); + s.extend_from_slice(e.as_bytes()); + s + } + }; + + // 3d. Bind to the entry-point symbol. + // + // Safety: the cast to `EntryPointFn` is unchecked โ€” if + // the symbol exists with a different signature, calls + // will silently misbehave. Mitigation: the ABI version + // handshake (step 6) catches mismatched plugins. + let entry: libloading::Symbol = match unsafe { + library.get::(&symbol_name) + } { + Ok(sym) => sym, + Err(e) => { + let symbol_display = std::str::from_utf8(&symbol_name) + .unwrap_or(""); + let hint = match &parsed.entry { + None => "did you use the cpex_dynamic_plugin! macro?".to_string(), + Some(entry_name) => match manifest { + Some(m) => { + let available: Vec<&str> = + m.entries.iter().map(|e| e.entry).collect(); + format!( + "available entries per the cdylib's manifest: [{}]", + available.join(", "), + ) + } + None => format!( + "cdylib does not expose a manifest, so the host can't \ + list available entries โ€” check the plugin's documentation. \ + You requested entry '{entry_name}'.", + ), + }, + }; + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' does not export '{}' ({}): {e}", + config.name, + parsed.library_path, + symbol_display, + hint, + ), + })); + } + }; + + // 4. Serialize the PluginConfig the plugin will deserialize. + let config_bytes = serde_json::to_vec(config).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': failed to serialize PluginConfig for plugin entry point: {e}", + config.name, + ), + }) + })?; + + // 5. Call the entry point. Out-pointer is what the plugin + // writes its `Box::into_raw` registration through. + let mut out_registration: *mut PluginRegistration = std::ptr::null_mut(); + let result = unsafe { + entry( + ABI_VERSION, + config_bytes.as_ptr(), + config_bytes.len(), + &mut out_registration as *mut *mut PluginRegistration, + ) + }; + + // 6. Translate the entry-point result. + match result { + EntryPointResult::Ok => {} + EntryPointResult::AbiMismatch => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' was compiled against a different \ + cpex-dynamic-plugin ABI version than the host (host: {}). \ + Rebuild the plugin against the same cpex-core / \ + cpex-dynamic-plugin versions the host is using.", + config.name, parsed.library_path, ABI_VERSION, + ), + })); + } + EntryPointResult::ConfigParseError => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' rejected its PluginConfig โ€” \ + likely a structural mismatch between operator's YAML \ + and the plugin's expected config schema", + config.name, parsed.library_path, + ), + })); + } + EntryPointResult::InitializationError => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' failed to initialize (the \ + plugin's create closure returned an error โ€” check the \ + cdylib's logs / stderr for details)", + config.name, parsed.library_path, + ), + })); + } + EntryPointResult::Panic => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' panicked during construction. \ + Caught at the FFI boundary; check the cdylib's logs / \ + stderr for the panic message and backtrace", + config.name, parsed.library_path, + ), + })); + } + } + + if out_registration.is_null() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' returned EntryPointResult::Ok but \ + left out_registration null โ€” plugin's cpex_plugin_create \ + implementation is buggy", + config.name, parsed.library_path, + ), + })); + } + + // 7. Take ownership of the registration. + // Safety: plugin wrote a valid `Box::into_raw` pointer + // per the ABI contract; we reclaim it here. Same + // allocator on both sides (system) per the spec. + let registration: PluginRegistration = + *unsafe { Box::from_raw(out_registration) }; + + // 8. Optional handler filter: if the kind had a `#handler` + // fragment, keep only the matching one. + let handlers = match parsed.handler { + None => registration.handlers, + Some(wanted) => { + let mut filtered: Vec<(String, Arc)> = + registration + .handlers + .into_iter() + .filter(|(name, _)| name == &wanted) + .collect(); + if filtered.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' returned no handler named '{}' \ + (the `#{}` fragment in the kind selected a handler that \ + the plugin didn't register)", + config.name, parsed.library_path, wanted, wanted, + ), + })); + } + // Reorder so the named handler is first (deterministic). + filtered.sort_by(|a, b| a.0.cmp(&b.0)); + filtered + } + }; + + if handlers.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' returned a PluginRegistration with \ + zero handlers โ€” plugin must register at least one", + config.name, parsed.library_path, + ), + })); + } + + // 9. Convert to PluginInstance shape. + // PluginInstance.handlers uses `&'static str` for the + // hook name; we transmute via `Box::leak(name.into_boxed_str())`. + // The handler-name strings are tiny and we already + // accepted the library leak, so adding string leaks is + // proportionate. + let leaked_handlers: Vec<(&'static str, Arc)> = + handlers + .into_iter() + .map(|(name, handler)| { + let leaked: &'static str = + Box::leak(name.into_boxed_str()); + (leaked, handler) + }) + .collect(); + + tracing::info!( + plugin_name = %config.name, + library = %parsed.library_path, + entry = parsed.entry.as_deref().unwrap_or(""), + plugin_reported_name = %registration.name, + plugin_reported_version = %registration.version, + handler_count = leaked_handlers.len(), + "loaded dynamic plugin", + ); + + Ok(PluginInstance { + plugin: registration.plugin, + handlers: leaked_handlers, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_kind_simple() { + let parsed = parse_kind("lib:/opt/plugins/foo.so", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/plugins/foo.so"); + assert_eq!(parsed.entry, None); + assert_eq!(parsed.handler, None); + } + + #[test] + fn parse_kind_with_handler_fragment() { + let parsed = + parse_kind("lib:/opt/plugins/foo.so#my_handler", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/plugins/foo.so"); + assert_eq!(parsed.entry, None); + assert_eq!(parsed.handler.as_deref(), Some("my_handler")); + } + + #[test] + fn parse_kind_with_relative_path() { + let parsed = parse_kind("lib:./plugins/foo.so", "lib").unwrap(); + assert_eq!(parsed.library_path, "./plugins/foo.so"); + } + + #[test] + fn parse_kind_windows_path() { + // Windows drive-letter colon should pass through โ€” we split + // on the FIRST colon only. + let parsed = parse_kind("lib:/C:/plugins/foo.dll", "lib").unwrap(); + assert_eq!(parsed.library_path, "/C:/plugins/foo.dll"); + } + + #[test] + fn parse_kind_wrong_scheme_errors() { + let err = parse_kind("wasm:/opt/foo.wasm", "lib").unwrap_err(); + assert!(err.contains("scheme 'wasm'")); + assert!(err.contains("registered for scheme 'lib'")); + } + + #[test] + fn parse_kind_missing_scheme_errors() { + let err = parse_kind("/opt/foo.so", "lib").unwrap_err(); + assert!(err.contains("missing scheme prefix")); + } + + #[test] + fn parse_kind_empty_path_errors() { + let err = parse_kind("lib:", "lib").unwrap_err(); + assert!(err.contains("empty library path")); + } + + #[test] + fn parse_kind_empty_handler_fragment_treated_as_empty_string() { + // `lib:/foo.so#` โ†’ handler = Some("") โ€” unusual but + // we let the create() filter step catch it via the + // "no handler named ''" error path. + let parsed = parse_kind("lib:/foo.so#", "lib").unwrap(); + assert_eq!(parsed.handler.as_deref(), Some("")); + } + + // ----- ?entry= query-string parsing ----- + + #[test] + fn parse_kind_with_entry_query() { + let parsed = parse_kind("lib:/opt/multi.so?entry=foo", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/multi.so"); + assert_eq!(parsed.entry.as_deref(), Some("foo")); + assert_eq!(parsed.handler, None); + } + + #[test] + fn parse_kind_with_entry_and_handler() { + // Full URL shape: path + query + fragment. + let parsed = + parse_kind("lib:/opt/multi.so?entry=foo#my_handler", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/multi.so"); + assert_eq!(parsed.entry.as_deref(), Some("foo")); + assert_eq!(parsed.handler.as_deref(), Some("my_handler")); + } + + #[test] + fn parse_kind_entry_with_underscore_and_digits() { + // Valid C identifier characters all the way through. + let parsed = + parse_kind("lib:/opt/multi.so?entry=rate_limiter_v2", "lib").unwrap(); + assert_eq!(parsed.entry.as_deref(), Some("rate_limiter_v2")); + } + + #[test] + fn parse_kind_entry_starting_with_underscore() { + let parsed = parse_kind("lib:/opt/multi.so?entry=_private", "lib").unwrap(); + assert_eq!(parsed.entry.as_deref(), Some("_private")); + } + + #[test] + fn parse_kind_entry_starting_with_digit_errors() { + // C identifiers can't start with a digit. + let err = parse_kind("lib:/opt/multi.so?entry=1foo", "lib").unwrap_err(); + assert!(err.contains("must start with a letter or underscore")); + } + + #[test] + fn parse_kind_entry_with_invalid_char_errors() { + let err = + parse_kind("lib:/opt/multi.so?entry=foo-bar", "lib").unwrap_err(); + assert!(err.contains("invalid character")); + } + + #[test] + fn parse_kind_empty_entry_value_errors() { + let err = parse_kind("lib:/opt/multi.so?entry=", "lib").unwrap_err(); + assert!(err.contains("empty 'entry=' value")); + } + + #[test] + fn parse_kind_unknown_query_param_errors() { + let err = + parse_kind("lib:/opt/multi.so?other=value", "lib").unwrap_err(); + assert!(err.contains("unknown query parameter 'other'")); + } + + #[test] + fn parse_kind_multi_param_query_errors() { + // `&` separator is rejected โ€” only one param supported. + let err = + parse_kind("lib:/opt/multi.so?entry=foo&extra=bar", "lib").unwrap_err(); + assert!(err.contains("multi-parameter query")); + } + + #[test] + fn parse_kind_malformed_query_errors() { + let err = parse_kind("lib:/opt/multi.so?noequalssign", "lib").unwrap_err(); + assert!(err.contains("malformed query")); + } + + #[test] + fn parse_kind_empty_query_string_treated_as_no_entry() { + // Trailing `?` with no content โ€” we treat it as no entry + // rather than an error, since the operator's intent is + // clear (just a stray character). + let parsed = parse_kind("lib:/opt/multi.so?", "lib").unwrap(); + assert_eq!(parsed.entry, None); + } + + // ----- validate_entry_ident ----- + + #[test] + fn validate_entry_ident_accepts_valid_names() { + assert!(validate_entry_ident("foo").is_ok()); + assert!(validate_entry_ident("foo_bar").is_ok()); + assert!(validate_entry_ident("_private").is_ok()); + assert!(validate_entry_ident("a").is_ok()); + assert!(validate_entry_ident("rate_limiter_v2").is_ok()); + } + + #[test] + fn validate_entry_ident_rejects_empty() { + assert!(validate_entry_ident("").is_err()); + } + + #[test] + fn validate_entry_ident_rejects_leading_digit() { + assert!(validate_entry_ident("1foo").is_err()); + assert!(validate_entry_ident("123").is_err()); + } + + #[test] + fn validate_entry_ident_rejects_special_chars() { + assert!(validate_entry_ident("foo-bar").is_err()); + assert!(validate_entry_ident("foo.bar").is_err()); + assert!(validate_entry_ident("foo bar").is_err()); + assert!(validate_entry_ident("foo!").is_err()); + assert!(validate_entry_ident("foo$bar").is_err()); + } +} diff --git a/crates/cpex-dynamic-plugin/src/lib.rs b/crates/cpex-dynamic-plugin/src/lib.rs new file mode 100644 index 00000000..49ee2186 --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/lib.rs @@ -0,0 +1,55 @@ +// Location: ./crates/cpex-dynamic-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// cpex-dynamic-plugin โ€” load Rust cdylib plugins at runtime. +// +// See `docs/specs/cpex-rust-spec.md` ยง17 for the architecture. +// +// # Module layout +// +// * `abi` โ€” shared types crossing the dlopen boundary +// (entry-point fn signature, registration struct, +// version constants). Always available. +// * `plugin` โ€” helpers for plugin authors writing a `cdylib`: +// the `cpex_dynamic_plugin!` macro that generates +// the `extern "C"` entry point, helpers to build a +// `PluginRegistration`. Always available. +// * `host` โ€” `DynamicPluginFactory` + `libloading`-backed +// loader. Behind the `host` feature flag โ€” plugin- +// only builds don't pay for libloading. +// +// # ABI versioning +// +// `abi::ABI_VERSION` is bumped whenever the entry-point signature, +// the `PluginRegistration` layout, or the underlying +// `AnyHookHandler` trait changes shape. Host loads a plugin โ†’ host +// asks plugin which ABI version it was compiled against โ†’ if +// mismatch, host refuses to load and returns a clear error. Same- +// version-only Rust ABI is the load-bearing constraint; this +// runtime check makes mismatches loud instead of UB. + +pub mod abi; +pub mod plugin; + +#[cfg(feature = "host")] +pub mod host; + +pub use abi::{ + EntryPointFn, EntryPointResult, ListFn, PluginManifest, PluginManifestEntry, + PluginRegistration, ABI_VERSION, ENTRY_POINT_SYMBOL, LIST_SYMBOL, +}; +pub use plugin::{dispatch_create, CreateFn}; + +#[cfg(feature = "host")] +pub use host::DynamicPluginFactory; + +// `paste` is re-exported under a hidden module so the +// `cpex_dynamic_plugins!` macro can reference it as +// `$crate::__macro_support::paste::paste!`. Plugin authors don't +// touch this directly โ€” they just use the macro. +#[doc(hidden)] +pub mod __macro_support { + pub use paste; +} diff --git a/crates/cpex-dynamic-plugin/src/plugin.rs b/crates/cpex-dynamic-plugin/src/plugin.rs new file mode 100644 index 00000000..f42d88ee --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/plugin.rs @@ -0,0 +1,450 @@ +// Location: ./crates/cpex-dynamic-plugin/src/plugin.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin-author helpers for writing a Rust `cdylib` plugin. +// +// Plugin authors write a normal Rust struct implementing `Plugin` +// + `HookHandler` (per the in-tree plugin recipe), then use +// the [`cpex_dynamic_plugin!`] macro to generate the `extern "C"` +// entry point. The macro handles: +// +// * ABI-version handshake (rejects mismatched hosts loudly). +// * Config deserialization from the raw bytes the host passes. +// * `catch_unwind` around user code so a panic doesn't unwind +// across the `extern "C"` boundary (which would be UB). +// * Allocating + transferring ownership of `PluginRegistration`. +// +// Plugin authors never write unsafe FFI by hand. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use cpex_core::plugin::PluginConfig; + +use crate::abi::{EntryPointResult, PluginRegistration, ABI_VERSION}; + +/// The closure plugin authors hand to [`dispatch_create`] / +/// [`cpex_dynamic_plugin!`]. Receives the deserialized +/// `PluginConfig`, returns either a populated +/// `PluginRegistration` (success) or a string (initialization +/// error โ€” wraps as [`EntryPointResult::InitializationError`]). +pub type CreateFn = + fn(PluginConfig) -> Result; + +/// Helper called from the plugin's generated `cpex_plugin_create` +/// function. Plugin authors should NOT call this directly โ€” use +/// the [`cpex_dynamic_plugin!`] macro, which generates the +/// correct unsafe glue. +/// +/// # Safety +/// +/// * `plugin_config_json` / `plugin_config_len` must describe a +/// valid byte slice (UTF-8 isn't required at this layer โ€” +/// `serde_json` will report a parse error if it isn't). +/// * `out_registration` must be non-null and writable. +/// +/// On every error variant, `*out_registration` is left untouched. +/// On `Ok`, `*out_registration` is set to a `Box::into_raw` pointer +/// the host takes ownership of. +pub unsafe fn dispatch_create( + host_abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut PluginRegistration, + create: CreateFn, +) -> EntryPointResult { + if host_abi_version != ABI_VERSION { + return EntryPointResult::AbiMismatch; + } + + // Materialize the config bytes into a borrowed slice. The + // host owns the storage; the plugin only reads. + let config_bytes = if plugin_config_json.is_null() || plugin_config_len == 0 { + &[][..] + } else { + // Safety: caller guarantees a valid byte range. Empty case + // handled above so we never construct a slice from null. + unsafe { std::slice::from_raw_parts(plugin_config_json, plugin_config_len) } + }; + + let config: PluginConfig = match serde_json::from_slice(config_bytes) { + Ok(c) => c, + Err(_) => return EntryPointResult::ConfigParseError, + }; + + // catch_unwind so a panic in user code doesn't propagate + // across the FFI boundary. AssertUnwindSafe because we don't + // care about state-after-panic โ€” the plugin's create() is + // single-shot and any partial state is dropped here. + let result = catch_unwind(AssertUnwindSafe(|| create(config))); + + let registration = match result { + Ok(Ok(r)) => r, + Ok(Err(_)) => return EntryPointResult::InitializationError, + Err(_) => return EntryPointResult::Panic, + }; + + // Transfer ownership to the host. Box::into_raw produces a + // raw pointer the host's `Box::from_raw` reclaims. + let boxed = Box::new(registration); + let ptr = Box::into_raw(boxed); + // Safety: caller guarantees out_registration is writable. + unsafe { + *out_registration = ptr; + } + EntryPointResult::Ok +} + +/// Generate the `#[no_mangle] pub extern "C" fn cpex_plugin_create` +/// entry point for a Rust `cdylib` plugin. +/// +/// # Usage +/// +/// ```rust,ignore +/// use cpex_core::{hooks::adapter::TypedHandlerAdapter, plugin::{Plugin, PluginConfig}, hooks::trait_def::HookHandler, cmf::CmfHook}; +/// use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; +/// use std::sync::Arc; +/// +/// struct MyPlugin { cfg: PluginConfig } +/// // ... impl Plugin + HookHandler for MyPlugin ... +/// +/// cpex_dynamic_plugin! { +/// |cfg: PluginConfig| -> Result { +/// let plugin = Arc::new(MyPlugin { cfg }); +/// let adapter = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); +/// Ok(PluginRegistration::new( +/// "my-plugin", +/// env!("CARGO_PKG_VERSION"), +/// plugin as Arc, +/// vec![("cmf.tool_pre_invoke".to_string(), adapter as Arc)], +/// )) +/// } +/// } +/// ``` +/// +/// The macro expands to a single `#[no_mangle] pub extern "C" fn +/// cpex_plugin_create(...)` that delegates to [`dispatch_create`]. +/// All unsafe-FFI plumbing is hidden. +#[macro_export] +macro_rules! cpex_dynamic_plugin { + ($create:expr) => { + /// Plugin entry point. Host's `DynamicPluginFactory` finds + /// this via `libloading::Library::get(b"cpex_plugin_create")`. + /// + /// Generated by `cpex_dynamic_plugin!`. Do not edit by hand. + #[no_mangle] + pub unsafe extern "C" fn cpex_plugin_create( + host_abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut $crate::abi::PluginRegistration, + ) -> $crate::abi::EntryPointResult { + // Cast the user closure to the function-pointer type + // `dispatch_create` expects. Plugin authors write + // `|cfg: PluginConfig| -> Result { ... }`. + let create_fn: $crate::plugin::CreateFn = $create; + unsafe { + $crate::plugin::dispatch_create( + host_abi_version, + plugin_config_json, + plugin_config_len, + out_registration, + create_fn, + ) + } + } + }; +} + +/// Generate entry points for MULTIPLE plugins packaged in one +/// cdylib, plus the optional `cpex_plugin_list` discovery symbol. +/// +/// Use this when you want to ship several distinct plugins inside +/// one shared object file. For single-plugin cdylibs, the simpler +/// [`cpex_dynamic_plugin!`] macro is the right tool โ€” it emits +/// `cpex_plugin_create` (no entry selector, no manifest needed). +/// +/// # Usage +/// +/// ```rust,ignore +/// use cpex_core::plugin::{Plugin, PluginConfig}; +/// use cpex_dynamic_plugin::{cpex_dynamic_plugins, PluginRegistration}; +/// +/// fn build_rate_limiter(cfg: PluginConfig) -> Result { +/// // ... build and return PluginRegistration ... +/// # unimplemented!() +/// } +/// +/// fn build_audit(cfg: PluginConfig) -> Result { +/// // ... build and return PluginRegistration ... +/// # unimplemented!() +/// } +/// +/// cpex_dynamic_plugins! { +/// rate_limiter => { +/// name: "Rate Limiter", +/// version: "1.0.0", +/// description: "Token-bucket rate limiter", +/// create: build_rate_limiter, +/// }, +/// audit => { +/// name: "Audit Logger", +/// version: "0.5.0", +/// description: "Writes hook events to disk", +/// create: build_audit, +/// }, +/// } +/// ``` +/// +/// # Operator side +/// +/// Each entry becomes addressable from YAML via `?entry=`: +/// +/// ```yaml +/// plugins: +/// - name: edge-rate-limit +/// kind: "lib:/opt/plugins/multi.so?entry=rate_limiter" +/// hooks: [cmf.tool_pre_invoke] +/// config: +/// max_per_second: 100 +/// - name: audit-trail +/// kind: "lib:/opt/plugins/multi.so?entry=audit" +/// hooks: [cmf.tool_post_invoke] +/// config: +/// log_path: /var/log/cpex-audit.log +/// ``` +/// +/// # What the macro generates +/// +/// * One `#[no_mangle] pub unsafe extern "C" fn +/// cpex_plugin_create_(...)` per entry, each wrapping the +/// supplied `create` function via `dispatch_create` (same ABI +/// glue as the single-plugin macro). +/// * A `static` [`PluginManifest`](crate::abi::PluginManifest) +/// listing all entries. +/// * A `#[no_mangle] pub unsafe extern "C" fn cpex_plugin_list()` +/// returning a pointer to that manifest. The host uses this to +/// (a) validate the operator's `?entry=` against the available +/// entries and (b) produce friendly errors when the requested +/// entry doesn't exist. +/// +/// # Entry naming +/// +/// The entry name (the ident before `=>`) MUST be a valid Rust +/// identifier โ€” that's what the macro requires, and it's also a +/// valid C identifier for the generated symbol. The same name +/// appears verbatim in: +/// +/// * The exported symbol: `cpex_plugin_create_`. +/// * The manifest's `entry` field (as a string). +/// * The operator's `?entry=` URL. +#[macro_export] +macro_rules! cpex_dynamic_plugins { + ( $( $entry:ident => { + name: $name:literal, + version: $version:literal, + description: $desc:literal, + create: $create:expr $(,)? + } ),+ $(,)? ) => { + $( + $crate::__macro_support::paste::paste! { + /// One entry point of a multi-plugin cdylib. Host's + /// `DynamicPluginFactory` finds this via + /// `libloading::Library::get(b"cpex_plugin_create_")` + /// when the operator's kind URL contains + /// `?entry=`. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + #[no_mangle] + pub unsafe extern "C" fn []( + host_abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut $crate::abi::PluginRegistration, + ) -> $crate::abi::EntryPointResult { + let create_fn: $crate::plugin::CreateFn = $create; + unsafe { + $crate::plugin::dispatch_create( + host_abi_version, + plugin_config_json, + plugin_config_len, + out_registration, + create_fn, + ) + } + } + } + )+ + + /// The cdylib's plugin manifest. Compile-time constant + /// referenced by the generated `cpex_plugin_list` symbol. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + static __CPEX_PLUGIN_MANIFEST_ENTRIES: &[$crate::abi::PluginManifestEntry] = &[ + $( + $crate::abi::PluginManifestEntry { + entry: stringify!($entry), + name: $name, + version: $version, + description: $desc, + }, + )+ + ]; + + /// The full manifest, referenced by `cpex_plugin_list`. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + static __CPEX_PLUGIN_MANIFEST: $crate::abi::PluginManifest = $crate::abi::PluginManifest { + abi_version: $crate::abi::ABI_VERSION, + entries: __CPEX_PLUGIN_MANIFEST_ENTRIES, + }; + + /// Discovery symbol. Optional in the ABI; emitted only when + /// the multi-plugin macro is used. Host's + /// `DynamicPluginFactory` looks for this via + /// `libloading::Library::get(b"cpex_plugin_list")` and uses + /// the result to validate `?entry=` against available + /// entries. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + #[no_mangle] + pub unsafe extern "C" fn cpex_plugin_list() -> *const $crate::abi::PluginManifest { + &__CPEX_PLUGIN_MANIFEST as *const $crate::abi::PluginManifest + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use cpex_core::plugin::{Plugin, PluginConfig}; + + /// A bare-minimum plugin used for testing `dispatch_create`. + /// Doesn't register any hook handlers; just verifies that the + /// flow (abi check, config parse, catch_unwind, registration + /// allocation) works end-to-end without a real cdylib build. + #[derive(Debug)] + struct StubPlugin { + cfg: PluginConfig, + } + + #[async_trait::async_trait] + impl Plugin for StubPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + fn stub_create(cfg: PluginConfig) -> Result { + let plugin = Arc::new(StubPlugin { cfg }); + Ok(PluginRegistration::new( + "stub-plugin", + "0.0.1", + plugin as Arc, + Vec::new(), + )) + } + + fn stub_create_failing(_cfg: PluginConfig) -> Result { + Err("simulated init failure".to_string()) + } + + fn stub_create_panicking(_cfg: PluginConfig) -> Result { + panic!("simulated panic in plugin init"); + } + + fn run_dispatch( + host_abi_version: u32, + config_bytes: &[u8], + create: CreateFn, + ) -> (EntryPointResult, *mut PluginRegistration) { + let mut out: *mut PluginRegistration = std::ptr::null_mut(); + let result = unsafe { + dispatch_create( + host_abi_version, + config_bytes.as_ptr(), + config_bytes.len(), + &mut out as *mut *mut PluginRegistration, + create, + ) + }; + (result, out) + } + + /// Helper: build the minimal serialized PluginConfig the + /// dispatch layer expects. + fn minimal_config_bytes() -> Vec { + let cfg = PluginConfig { + name: "test".to_string(), + kind: "lib:/dev/null".to_string(), + ..Default::default() + }; + serde_json::to_vec(&cfg).expect("PluginConfig serializes") + } + + #[test] + fn happy_path_returns_ok_and_populates_out() { + let bytes = minimal_config_bytes(); + let (result, out) = run_dispatch(ABI_VERSION, &bytes, stub_create); + assert_eq!(result, EntryPointResult::Ok); + assert!(!out.is_null()); + // Take ownership and verify the registration fields. + let boxed = unsafe { Box::from_raw(out) }; + assert_eq!(boxed.abi_version, ABI_VERSION); + assert_eq!(boxed.name, "stub-plugin"); + assert_eq!(boxed.version, "0.0.1"); + assert!(boxed.handlers.is_empty()); + } + + #[test] + fn abi_mismatch_short_circuits_before_user_code() { + // host_abi_version != ABI_VERSION โ†’ dispatch returns + // AbiMismatch without ever touching the config or + // invoking the create closure. + let bytes = minimal_config_bytes(); + let (result, out) = + run_dispatch(ABI_VERSION.wrapping_add(1), &bytes, stub_create); + assert_eq!(result, EntryPointResult::AbiMismatch); + assert!(out.is_null(), "out must be untouched on AbiMismatch"); + } + + #[test] + fn config_parse_error_returns_config_parse_error() { + let bytes = b"this isn't json"; + let (result, out) = run_dispatch(ABI_VERSION, bytes, stub_create); + assert_eq!(result, EntryPointResult::ConfigParseError); + assert!(out.is_null()); + } + + #[test] + fn empty_config_is_treated_as_parse_error() { + // An empty config-bytes range deserializes to error + // (`serde_json::from_slice(&[])` fails). Plugin authors + // who want to support "no config" should send `{}`. + let (result, _) = run_dispatch(ABI_VERSION, &[], stub_create); + assert_eq!(result, EntryPointResult::ConfigParseError); + } + + #[test] + fn init_failure_returns_initialization_error() { + let bytes = minimal_config_bytes(); + let (result, out) = run_dispatch(ABI_VERSION, &bytes, stub_create_failing); + assert_eq!(result, EntryPointResult::InitializationError); + assert!(out.is_null()); + } + + #[test] + fn panic_in_user_code_is_caught_and_reported() { + // catch_unwind catches the panic; dispatch returns + // Panic. Critical for safety โ€” an unwinding panic across + // extern "C" is undefined behavior, so the host must + // never see one. + let bytes = minimal_config_bytes(); + let (result, out) = run_dispatch(ABI_VERSION, &bytes, stub_create_panicking); + assert_eq!(result, EntryPointResult::Panic); + assert!(out.is_null()); + } +} diff --git a/crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs b/crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs new file mode 100644 index 00000000..31954f8b --- /dev/null +++ b/crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs @@ -0,0 +1,824 @@ +// Location: ./crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for `DynamicPluginFactory` against a real cdylib. +// +// Exercises the full slice: the example plugin (compiled as a +// cdylib by cargo via the dev-dep edge) โ†’ `DynamicPluginFactory` +// dlopens it โ†’ registration handshake โ†’ `PluginInstance` +// construction โ†’ invoke via `PluginManager` โ†’ assert outcome. +// +// This is the load-bearing "the unsafe glue actually works" +// test. The unit tests in `host.rs` cover the kind-string parser +// in isolation; this test wires through libloading + the entry +// point + Box::from_raw + handler dispatch end-to-end. +// +// # Why the file requires `--features host` +// +// `DynamicPluginFactory` lives behind the `host` feature flag in +// cpex-dynamic-plugin. The integration test is automatically +// included when running `cargo test --features host`. Plain +// `cargo test` (default features only) skips this file's tests +// because the `DynamicPluginFactory` symbol isn't visible. + +#![cfg(feature = "host")] + +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::Extensions; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use cpex_dynamic_plugin::DynamicPluginFactory; + +/// Name of the allow-gate example plugin crate. Cargo turns +/// hyphens into underscores when forming the artifact filename. +const EXAMPLE_CRATE: &str = "cpex_dynamic_plugin_example"; + +/// Name of the multi-handler example plugin crate. Registers two +/// handlers: pre-invoke allow + post-invoke deny. +const MULTI_HANDLER_CRATE: &str = "cpex_dynamic_plugin_multi_handler_example"; + +/// Name of the multi-plugin example crate. Packages TWO distinct +/// plugins (allow + deny) under `?entry=allow` and `?entry=deny`. +const MULTI_PLUGIN_CRATE: &str = "cpex_dynamic_plugin_multi_plugin_example"; + +/// Locate a cdylib in the workspace target directory by crate +/// name. Uses `CARGO_MANIFEST_DIR` (the cpex-dynamic-plugin +/// crate's dir, set by cargo at test build time) and walks up to +/// the workspace root. Profile defaults to `debug`; tests run +/// `cargo test` which is the debug profile. +fn cdylib_path(crate_name: &str) -> PathBuf { + // CARGO_MANIFEST_DIR = /crates/cpex-dynamic-plugin + // Walk up two levels to get to . + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = crate_dir + .parent() // crates/ + .and_then(|p| p.parent()) // / + .expect("workspace root reachable from CARGO_MANIFEST_DIR") + .to_path_buf(); + + // Profile: `cargo test --release` would put it in `release/`, + // but the default `cargo test` is `debug/`. Detect via the + // `PROFILE` env var if set, else default debug. + let profile = option_env!("PROFILE").unwrap_or("debug"); + + // Filename: `lib.dylib` on macOS, `lib.so` on + // Linux, `.dll` on Windows. `DLL_PREFIX` is "lib" on + // unix and "" on windows; `DLL_SUFFIX` is the right thing + // per-OS. + let filename = format!( + "{prefix}{crate_name}{suffix}", + prefix = std::env::consts::DLL_PREFIX, + crate_name = crate_name, + suffix = std::env::consts::DLL_SUFFIX, + ); + + workspace_root + .join("target") + .join(profile) + .join(filename) +} + +/// Build the `kind:` string for a workspace cdylib. The dev-dep +/// edge from cpex-dynamic-plugin to the example plugins guarantees +/// cargo has built the cdylib before this test runs. +fn cdylib_kind(crate_name: &str) -> String { + let path = cdylib_path(crate_name); + assert!( + path.exists(), + "plugin cdylib not found at {} โ€” \ + the dev-dependency on {} should have triggered the build. \ + Try `cargo test -p cpex-dynamic-plugin --features host`", + path.display(), + crate_name, + ); + format!("lib:{}", path.display()) +} + +fn example_kind() -> String { + cdylib_kind(EXAMPLE_CRATE) +} + +/// Build a `PluginConfig` referencing the example plugin via the +/// URL-shaped kind, with the operator's own plugin config +/// (a no-op `{}` for the example). +fn example_plugin_config() -> PluginConfig { + PluginConfig { + name: "example".into(), + kind: example_kind(), + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(serde_json::json!({})), + ..Default::default() + } +} + +#[tokio::test] +async fn factory_loads_example_cdylib_and_executor_invokes_it() { + // 1. Set up a manager + register the dynamic-plugin factory + // under scheme "lib". + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + // 2. Build the plugin config that points at the example cdylib + // and load it via the standard factory-driven path. We use + // register_handler under the hood via a small helper rather + // than going through load_config_yaml โ€” that path needs a + // full YAML; we already have a typed PluginConfig. + // + // The factory's `create()` is what dlopens the library, + // binds to cpex_plugin_create, calls it, and produces a + // PluginInstance. We then hand that PluginInstance's + // handlers to the manager via register_raw. + let cfg = example_plugin_config(); + + // The most direct path that exercises the factory: ask the + // manager to load a config containing the plugin. We build a + // minimal YAML and feed it. + let yaml = format!( + r#" +plugins: + - name: {name} + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + name = cfg.name, + kind = cfg.kind, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("load_config_yaml should succeed against the example cdylib"); + mgr.initialize().await.unwrap(); + + // 3. Dispatch a CMF message through the manager. The example + // plugin's handler is `PluginResult::allow()` โ€” pipeline + // should continue. + let payload = MessagePayload { + message: Message::text(Role::User, "hello dynamic plugin"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + + assert!( + result.continue_processing, + "dynamic plugin's allow-gate should let the pipeline continue: \ + violation = {:?}", + result.violation, + ); + + // The example plugin doesn't mutate payload or extensions, but + // the executor returns the (possibly Box'd) original payload + // via `modified_payload`. Just confirm it's there. + assert!(result.modified_payload.is_some()); +} + +#[tokio::test] +async fn factory_reports_friendly_error_when_library_missing() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let yaml = r#" +plugins: + - name: missing + kind: "lib:/dev/null/definitely-not-a-real-cdylib.dylib" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {} +"#; + let parsed = cpex_core::config::parse_config(yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("missing cdylib should fail config load"); + let msg = format!("{err}"); + assert!( + msg.contains("dlopen") || msg.contains("failed to") || msg.to_lowercase().contains("not"), + "expected a dlopen-related error message, got: {msg}", + ); +} + +#[tokio::test] +async fn factory_rejects_wrong_scheme_in_kind() { + // The factory is registered for scheme "lib"; a kind starting + // with "wasm:" can't reach the factory at all โ€” the registry's + // get() returns None and the manager reports "no factory". + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let yaml = r#" +plugins: + - name: wrong-scheme + kind: "wasm:/path/to/foo.wasm" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {} +"#; + let parsed = cpex_core::config::parse_config(yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("unregistered scheme should fail config load"); + let msg = format!("{err}"); + assert!( + msg.contains("no factory") || msg.contains("wasm"), + "expected a no-factory diagnostic, got: {msg}", + ); +} + +// -------------------------------------------------------------------- +// Multi-handler + multi-plugin tests. +// +// These tests exercise two aspects of the loader that the single- +// handler happy path can't cover: +// +// 1. A cdylib that registers MORE THAN ONE handler with distinct +// hook names. We need to confirm the host wires each handler +// to its declared hook (not collapsed onto one) AND that the +// `#handler` URL fragment correctly filters down to a single +// handler when the operator wants only one. +// 2. TWO DIFFERENT cdylibs loaded simultaneously by the same +// PluginManager. We need to confirm that two `Box::leak`ed +// Library handles + two separate registrations don't step on +// each other (separate vtables, separate Arcs, separate +// handler maps). +// +// The multi-handler example registers: +// * cmf.tool_pre_invoke โ†’ AllowOnPre โ†’ continue_processing=true +// * cmf.tool_post_invoke โ†’ DenyOnPost โ†’ continue_processing=false +// violation.code = +// "test.multi_handler.post_deny" +// +// The verdict + violation code is the test's signal for "which +// handler ran". +// -------------------------------------------------------------------- + +/// Multi-handler cdylib loaded WITHOUT a fragment should register +/// both handlers, each under its declared hook name. Invoking +/// `cmf.tool_pre_invoke` should hit the allow path; invoking +/// `cmf.tool_post_invoke` should hit the deny path with the +/// distinctive violation code. +#[tokio::test] +async fn multi_handler_no_fragment_registers_both() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + // Note: `hooks:` declares BOTH hook names so the registry binds + // both handlers. The cdylib produces a PluginRegistration with + // two `(hook_name, handler)` pairs; the host filters by `hooks:` + // and by URL fragment. With no fragment and both hooks listed, + // both handlers are wired. + let kind = cdylib_kind(MULTI_HANDLER_CRATE); + let yaml = format!( + r#" +plugins: + - name: multi + kind: "{kind}" + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed).expect("multi-handler cdylib loads"); + mgr.initialize().await.unwrap(); + + // Pre-invoke โ†’ allow. + let pre_payload = MessagePayload { + message: Message::text(Role::User, "pre"), + }; + let (pre_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + pre_payload, + Extensions::default(), + None, + ) + .await; + assert!( + pre_result.continue_processing, + "AllowOnPre should allow pre-invoke; got violation={:?}", + pre_result.violation, + ); + + // Post-invoke โ†’ deny with the distinctive code. This proves the + // post-handler ran (not the pre-handler bound to the wrong hook). + let post_payload = MessagePayload { + message: Message::text(Role::User, "post"), + }; + let (post_result, _bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + Extensions::default(), + None, + ) + .await; + assert!( + !post_result.continue_processing, + "DenyOnPost should deny post-invoke", + ); + let violation = post_result + .violation + .expect("deny should carry a violation"); + assert_eq!( + violation.code, "test.multi_handler.post_deny", + "violation code identifies the post-handler as the one that fired", + ); +} + +/// With a `#cmf.tool_pre_invoke` fragment in the kind, the host +/// should filter the cdylib's registered handlers down to just the +/// pre-invoke one. Even if the operator lists `cmf.tool_post_invoke` +/// in `hooks:`, no handler should be wired there because the +/// fragment filtered the post-handler out of the registration. +#[tokio::test] +async fn multi_handler_with_fragment_filters_to_one() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!( + "{}#cmf.tool_pre_invoke", + cdylib_kind(MULTI_HANDLER_CRATE), + ); + // Only list the pre hook โ€” the fragment already filtered out + // the post handler, so listing post here would just fail the + // load with "no handler for hook". + let yaml = format!( + r#" +plugins: + - name: multi-filtered + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("fragment-filtered cdylib loads"); + mgr.initialize().await.unwrap(); + + // Pre still allows. + let payload = MessagePayload { + message: Message::text(Role::User, "pre"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + result.continue_processing, + "pre handler is the only one present and should allow", + ); + + // Post should have NO handler โ€” invoking the hook is a no-op + // (no plugins subscribed). Manager returns a continue verdict + // by default. Confirm we don't accidentally see the post-deny + // violation that would mean the fragment filter failed. + let post_payload = MessagePayload { + message: Message::text(Role::User, "post"), + }; + let (post_result, _bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + Extensions::default(), + None, + ) + .await; + assert!( + post_result.continue_processing, + "with no post handler wired, the post hook should be a no-op", + ); + assert!( + post_result.violation.is_none(), + "fragment filter failed: post-deny handler fired anyway. \ + violation = {:?}", + post_result.violation, + ); +} + +/// Two distinct cdylibs loaded into the SAME PluginManager. Each +/// is built independently, each is `Box::leak`ed by the host, and +/// both contribute to the hook pipelines without interfering with +/// each other. +/// +/// We load: +/// * `cpex-dynamic-plugin-example` โ†’ allow on pre +/// * `cpex-dynamic-plugin-multi-handler-example` โ†’ allow on pre + +/// deny on post +/// +/// On `cmf.tool_pre_invoke` both plugins fire and both allow, so +/// the pipeline continues. On `cmf.tool_post_invoke` only the +/// multi-handler plugin is wired (the example plugin doesn't +/// subscribe to it) and we get the deny verdict. This proves the +/// two libraries coexist without symbol clashes or shared state. +#[tokio::test] +async fn multiple_dynamic_plugins_coexist_in_one_manager() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let single_kind = cdylib_kind(EXAMPLE_CRATE); + let multi_kind = cdylib_kind(MULTI_HANDLER_CRATE); + let yaml = format!( + r#" +plugins: + - name: single + kind: "{single_kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} + - name: multi + kind: "{multi_kind}" + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 20 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("two distinct cdylibs load into one manager"); + mgr.initialize().await.unwrap(); + + // Both plugins subscribe to pre-invoke. Both allow โ†’ pipeline + // continues. If either Library got unloaded prematurely (drop- + // order hazard), invoking through the vtable would segfault + // here โ€” the test reaching the assert means both libraries are + // still mapped. + let pre_payload = MessagePayload { + message: Message::text(Role::User, "pre"), + }; + let (pre_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + pre_payload, + Extensions::default(), + None, + ) + .await; + assert!( + pre_result.continue_processing, + "both pre-invoke handlers should allow; got violation={:?}", + pre_result.violation, + ); + + // Only multi subscribes to post-invoke, and it denies. The fact + // that the single plugin's library hasn't trampled multi's + // registration is what we're confirming. + let post_payload = MessagePayload { + message: Message::text(Role::User, "post"), + }; + let (post_result, _bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + Extensions::default(), + None, + ) + .await; + assert!( + !post_result.continue_processing, + "multi's post-deny handler should fire even with a second \ + cdylib also loaded", + ); + let violation = post_result + .violation + .expect("deny should carry a violation"); + assert_eq!(violation.code, "test.multi_handler.post_deny"); +} + +// -------------------------------------------------------------------- +// Multi-plugin-per-cdylib tests (slice DL-3). +// +// These exercise the `?entry=` URL parameter and the optional +// `cpex_plugin_list` discovery symbol. The multi-plugin example +// cdylib packages TWO distinct plugins: +// +// * `?entry=allow` โ†’ allow-gate plugin โ†’ continue_processing=true +// * `?entry=deny` โ†’ deny-gate plugin โ†’ continue_processing=false, +// violation.code="test.multi_plugin.deny" +// +// Crucially, the multi-plugin cdylib does NOT export the default +// `cpex_plugin_create` symbol โ€” operators MUST select an entry. That +// gives the tests a way to confirm the host's symbol resolution is +// keyed off `?entry=` rather than always falling back to the default. +// -------------------------------------------------------------------- + +/// `?entry=allow` selects the allow-gate plugin from the multi- +/// plugin cdylib. Verdict is "allow," and the plugin instance +/// reports its plugin-author-set name `allow-gate`. +#[tokio::test] +async fn multi_plugin_entry_allow_loads_allow_gate() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!("{}?entry=allow", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: multi-allow + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("multi-plugin cdylib loads with ?entry=allow"); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "allow test"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + result.continue_processing, + "?entry=allow should select the allow-gate plugin", + ); +} + +/// `?entry=deny` selects the deny-gate plugin from the SAME cdylib. +/// Verdict is "deny" with the distinctive violation code, proving +/// the host routed to a different entry-point function than the +/// previous test (not just running the same plugin twice). +#[tokio::test] +async fn multi_plugin_entry_deny_loads_deny_gate() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!("{}?entry=deny", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: multi-deny + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("multi-plugin cdylib loads with ?entry=deny"); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "deny test"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + !result.continue_processing, + "?entry=deny should select the deny-gate plugin", + ); + let violation = result.violation.expect("deny should carry a violation"); + assert_eq!( + violation.code, "test.multi_plugin.deny", + "violation code identifies the deny entry as the one that ran", + ); +} + +/// BOTH entries of the SAME cdylib loaded into one PluginManager +/// under different operator-facing names. The cdylib is dlopen'd +/// twice (or once with refcount 2; the OS dedupes) but the host's +/// PluginInstance map has two distinct entries. Pipeline aggregates +/// to a deny because the deny-gate fires. +#[tokio::test] +async fn multi_plugin_both_entries_coexist() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let allow_kind = format!("{}?entry=allow", cdylib_kind(MULTI_PLUGIN_CRATE)); + let deny_kind = format!("{}?entry=deny", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: gate-allow + kind: "{allow_kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} + - name: gate-deny + kind: "{deny_kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("both entries of the multi-plugin cdylib load"); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "two-entry test"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + // Pipeline: allow-gate runs (priority 10) โ†’ allows โ†’ deny-gate + // runs (priority 20) โ†’ denies โ†’ pipeline result is the deny. + // The fact that the deny VIOLATION CODE shows up proves that + // the second plugin instance is the deny entry, not a second + // copy of the allow entry. + assert!( + !result.continue_processing, + "deny-gate (priority 20) should produce a deny verdict", + ); + let violation = result.violation.expect("deny should carry a violation"); + assert_eq!(violation.code, "test.multi_plugin.deny"); +} + +/// `?entry=nonexistent` should be rejected at load time with a +/// friendly diagnostic listing the available entries from the +/// cdylib's manifest. This is the load-bearing test for the +/// `cpex_plugin_list` discovery symbol โ€” if it weren't being read, +/// the error would just be a raw dlsym "symbol not found." +#[tokio::test] +async fn multi_plugin_unknown_entry_lists_available_entries() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!("{}?entry=nonexistent", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: bogus + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("unknown entry should fail config load"); + let msg = format!("{err}"); + assert!( + msg.contains("no entry 'nonexistent'"), + "expected diagnostic to mention the requested entry; got: {msg}", + ); + assert!( + msg.contains("allow") && msg.contains("deny"), + "expected diagnostic to list available entries [allow, deny]; got: {msg}", + ); +} + +/// A multi-plugin cdylib without `?entry=` should fail because it +/// doesn't export the default `cpex_plugin_create` symbol. The +/// error message tells the operator what's missing โ€” and since the +/// manifest IS available, the "no symbol" path can pivot to "did +/// you mean ?entry=allow or ?entry=deny?" via the hint. +/// +/// We assert the error mentions the missing default symbol; the +/// "did you use the macro?" hint applies here since `parsed.entry` +/// is None, which the host treats as the single-plugin case. +#[tokio::test] +async fn multi_plugin_without_entry_fails_with_helpful_error() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + // Note: no `?entry=` in the kind URL. + let kind = cdylib_kind(MULTI_PLUGIN_CRATE); + let yaml = format!( + r#" +plugins: + - name: no-entry + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("multi-plugin cdylib without ?entry= should fail"); + let msg = format!("{err}"); + assert!( + msg.contains("cpex_plugin_create"), + "expected diagnostic to mention the missing default symbol; got: {msg}", + ); +} + +/// Sanity check that the single-plugin cdylib still works after the +/// host gained `?entry=` support. Same code as the original happy- +/// path test but kept distinct so a regression in single-plugin +/// behavior is easy to identify. +#[tokio::test] +async fn single_plugin_path_still_works_with_no_entry() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let cfg = example_plugin_config(); + let yaml = format!( + r#" +plugins: + - name: {name} + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + name = cfg.name, + kind = cfg.kind, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("single-plugin cdylib still loads with no ?entry="); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "single-plugin compat"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + result.continue_processing, + "single-plugin allow-gate still wired correctly", + ); +} diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml new file mode 100644 index 00000000..73d55683 --- /dev/null +++ b/crates/cpex-ffi/Cargo.toml @@ -0,0 +1,31 @@ +# Location: ./crates/cpex-ffi/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX FFI โ€” C API for embedding the CPEX runtime in Go, Python, etc. +# Compiles to a shared library (cdylib) exporting extern "C" functions. +# Payloads cross the FFI boundary as MessagePack bytes. + +[package] +name = "cpex-ffi" +description = "CPEX C FFI โ€” shared library for Go/Python/WASM host bindings." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +rmp-serde = { workspace = true } +serde_bytes = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs new file mode 100644 index 00000000..760f62d9 --- /dev/null +++ b/crates/cpex-ffi/src/lib.rs @@ -0,0 +1,1313 @@ +// Location: ./crates/cpex-ffi/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX FFI โ€” C API for embedding the CPEX runtime. +// +// Exports extern "C" functions that Go (via cgo), Python (via ctypes/cffi), +// and other languages can call. Payloads and extensions cross the boundary +// as MessagePack bytes. ContextTable and BackgroundTasks are opaque handles. +// +// Each PluginManager owns its own tokio runtime so async plugin execution +// works from synchronous cgo calls. + +use std::os::raw::{c_char, c_int}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::sync::OnceLock; +use std::time::Duration; + +use cpex_core::context::PluginContextTable; +use cpex_core::executor::BackgroundTasks; +use cpex_core::extensions::Extensions; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::manager::PluginManager; + +// --------------------------------------------------------------------------- +// FFI Result Codes +// --------------------------------------------------------------------------- +// +// FFI functions return c_int. 0 means success; negative codes classify +// failures so the Go (or other-language) caller can produce a typed +// error rather than a single opaque "invoke failed" string. The codes +// are stable wire ABI โ€” additions go at the end with a fresh value; +// don't renumber. +// +// Mapped on the Go side in `go/cpex/manager.go::errorFromRC`. + +/// Operation succeeded. +pub const RC_OK: c_int = 0; +/// Manager handle is null or shut down. +pub const RC_INVALID_HANDLE: c_int = -1; +/// Caller-supplied input is malformed (bad UTF-8, null pointer where +/// data was required, oversized buffer, unknown payload type). +pub const RC_INVALID_INPUT: c_int = -2; +/// Parse / deserialize step failed (YAML config, MessagePack payload, +/// MessagePack extensions). +pub const RC_PARSE_ERROR: c_int = -3; +/// Pipeline / lifecycle step failed: `load_config` returned Err, +/// `initialize` returned Err, or a plugin signalled failure during +/// invoke without a wall-clock timeout or panic. +pub const RC_PIPELINE_ERROR: c_int = -4; +/// Result serialization (post-pipeline) failed โ€” usually OOM on +/// `rmp_serde::to_vec_named` or unserializable JSON value. +pub const RC_SERIALIZE_ERROR: c_int = -5; +/// Wall-clock timeout exceeded inside `run_safely` โ€” plugin likely +/// CPU-bound or blocking the OS thread without yielding. +pub const RC_TIMEOUT: c_int = -6; +/// Plugin panicked; caught by `catch_unwind` at the FFI boundary. +pub const RC_PANIC: c_int = -7; + +/// Outer wall-clock timeout for any FFI-driven async call. Per-plugin +/// `tokio::time::timeout` only catches cooperative-async timeouts; this +/// catches CPU-bound or thread-blocking plugins that never yield. Set +/// generously โ€” bigger than any reasonable per-plugin timeout โ€” so the +/// usual case never hits this bound. +const FFI_WALL_CLOCK_TIMEOUT: Duration = Duration::from_secs(60); + +// --------------------------------------------------------------------------- +// Shared Tokio Runtime +// --------------------------------------------------------------------------- +// +// One process-singleton runtime serves every manager rather than each +// `cpex_manager_new` building its own. With many managers (multi-tenant +// hosts that create one per request, dynamic plugin reload, etc.) the +// per-manager model exploded thread count: 100 managers ร— num_cpus +// workers each = hundreds of OS threads (and ~2MB stack apiece). +// +// Worker thread count precedence (highest first): +// 1. `cpex_configure_runtime(N)` โ€” explicit FFI call, before first use. +// 2. `CPEX_FFI_WORKER_THREADS` env var โ€” operator-friendly; read once +// on first use of `shared_runtime()`. +// 3. tokio default (`num_cpus`). +// +// Once the runtime is initialized it's fixed for the process lifetime. +static SHARED_RUNTIME: OnceLock = OnceLock::new(); + +/// Name of the env var operators set to bound worker threads without +/// recompiling host code or touching YAML. +const ENV_WORKER_THREADS: &str = "CPEX_FFI_WORKER_THREADS"; + +/// Parse `CPEX_FFI_WORKER_THREADS` if set. Returns `Some(n)` for valid +/// positive integers, `None` for unset / zero / negative / unparseable +/// values (in which case the runtime falls back to tokio's default). +/// Logs a warning on malformed values so operators see why their +/// setting was ignored. +/// +/// Extracted from `shared_runtime` so it's unit-testable without +/// touching the global `OnceLock`. +fn worker_threads_from_env() -> Option { + let raw = std::env::var(ENV_WORKER_THREADS).ok()?; + match raw.parse::() { + Ok(n) if n > 0 => Some(n), + Ok(_) => { + tracing::warn!( + "cpex-ffi: {}={} is not a positive integer; using num_cpus default", + ENV_WORKER_THREADS, + raw, + ); + None + } + Err(_) => { + tracing::warn!( + "cpex-ffi: {}={:?} is not parseable as a positive integer; using num_cpus default", + ENV_WORKER_THREADS, + raw, + ); + None + } + } +} + +/// Get (or lazily initialize on first call) the shared tokio runtime. +/// +/// On first call: respects `CPEX_FFI_WORKER_THREADS` if set. If the env +/// var is absent or invalid, defaults to tokio's `num_cpus`. The FFI +/// path `cpex_configure_runtime` overrides both โ€” it `set`s the +/// OnceLock before this function is called, so by the time +/// `get_or_init` runs the runtime is already there and the env var is +/// ignored. +fn shared_runtime() -> &'static tokio::runtime::Runtime { + SHARED_RUNTIME.get_or_init(|| { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.enable_all(); + if let Some(n) = worker_threads_from_env() { + builder.worker_threads(n); + tracing::info!( + "cpex-ffi: shared runtime using {} worker threads (from {})", + n, + ENV_WORKER_THREADS, + ); + } + builder + .build() + .expect("cpex-ffi: failed to build shared tokio runtime") + }) +} + +/// Configure the shared tokio runtime's worker thread count. +/// +/// Must be called *before* any `cpex_manager_new` / `cpex_manager_new_default` +/// call โ€” once a manager has been created the runtime is fixed for +/// the process lifetime. Returns `RC_OK` on success or +/// `RC_INVALID_INPUT` if the runtime has already been initialized +/// (or if `worker_threads` is non-positive). +/// +/// Use case: multi-tenant hosts that want to bound total worker +/// threads regardless of how many `PluginManager`s are alive. +/// +/// Precedence: this FFI call beats `CPEX_FFI_WORKER_THREADS` (the env +/// var is read only on lazy init via `shared_runtime()`; an explicit +/// `set` here populates the OnceLock first and short-circuits that +/// path). Operators can set the env var as a default; host code can +/// override. +/// +/// # Safety +/// Safe to call from a single thread before any manager creation. +/// Calling after a manager exists is well-defined (returns +/// `RC_INVALID_INPUT`) but does not change the active runtime. +#[no_mangle] +pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { + if worker_threads <= 0 { + return RC_INVALID_INPUT; + } + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads as usize) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + tracing::error!("cpex_configure_runtime: build failed: {}", e); + return RC_PIPELINE_ERROR; + } + }; + match SHARED_RUNTIME.set(rt) { + Ok(()) => RC_OK, + Err(_) => { + // Runtime already initialized โ€” caller should have set + // this before any manager creation. + tracing::warn!( + "cpex_configure_runtime: runtime already initialized; \ + configuration ignored. Call before any cpex_manager_new.", + ); + RC_INVALID_INPUT + } + } +} + +/// Outcome of `run_safely`. Lets the caller distinguish timeout vs. +/// panic so they can return the right `RC_*` code instead of collapsing +/// both into a generic failure. +enum SafeRun { + Ok(T), + Timeout, + Panicked, +} + +impl SafeRun { + /// Code to return from the FFI function on a non-Ok outcome. + /// On `Ok(_)` callers continue with the wrapped value; this is only + /// consulted on the failure paths. + fn rc(&self) -> c_int { + match self { + SafeRun::Ok(_) => RC_OK, + SafeRun::Timeout => RC_TIMEOUT, + SafeRun::Panicked => RC_PANIC, + } + } +} + +/// Run a future on the shared tokio runtime with two layers of safety: +/// +/// 1. `tokio::time::timeout` bounds the total wall-clock time. A plugin +/// that blocks an OS thread (rather than awaiting cooperatively) will +/// eventually surface as `Err(Elapsed)` here instead of hanging the +/// calling goroutine forever. +/// 2. `std::panic::catch_unwind` converts any panic that escapes the +/// pipeline into an `Err`, preventing it from unwinding across the +/// `extern "C"` boundary (which is UB on Rust < 1.81 and an abort on +/// >= 1.81). +/// +/// Returns a `SafeRun` so the caller can map the failure shape to a +/// specific `RC_*` code. +fn run_safely(fut: F, op_name: &str) -> SafeRun +where + F: std::future::Future, +{ + // `tokio::time::timeout` must be constructed inside an active runtime + // context โ€” it registers a timer with the runtime's timer driver. + // Wrap construction in an `async` block so it happens INSIDE block_on, + // not before. (Constructing it outside panics with "there is no + // reactor running".) + let result = catch_unwind(AssertUnwindSafe(|| { + shared_runtime() + .block_on(async move { tokio::time::timeout(FFI_WALL_CLOCK_TIMEOUT, fut).await }) + })); + match result { + Ok(Ok(value)) => SafeRun::Ok(value), + Ok(Err(_elapsed)) => { + tracing::error!( + "FFI {}: wall-clock timeout exceeded ({}s) โ€” plugin likely \ + not yielding (CPU-bound or std::thread::sleep)", + op_name, + FFI_WALL_CLOCK_TIMEOUT.as_secs(), + ); + SafeRun::Timeout + } + Err(_panic_payload) => { + tracing::error!( + "FFI {}: plugin panicked across FFI boundary โ€” caught to \ + prevent UB; returning failure to caller", + op_name, + ); + SafeRun::Panicked + } + } +} + +// --------------------------------------------------------------------------- +// Payload Type Registry +// --------------------------------------------------------------------------- + +/// Payload type IDs โ€” must match Go constants. +pub const PAYLOAD_GENERIC: u8 = 0; +pub const PAYLOAD_CMF_MESSAGE: u8 = 1; + +/// Deserialize a MessagePack payload based on its type ID. +/// Array-indexed โ€” O(1) lookup, zero allocation. +fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result, String> { + match payload_type { + PAYLOAD_GENERIC => { + let value: serde_json::Value = rmp_serde::from_slice(bytes) + .map_err(|e| format!("generic payload deserialize failed: {}", e))?; + Ok(Box::new(GenericPayload { value })) + } + PAYLOAD_CMF_MESSAGE => { + let msg: cpex_core::cmf::MessagePayload = rmp_serde::from_slice(bytes) + .map_err(|e| format!("CMF payload deserialize failed: {}", e))?; + Ok(Box::new(msg)) + } + _ => Err(format!("unknown payload type: {}", payload_type)), + } +} + +/// Serialize a modified payload back to MessagePack bytes. +/// Returns the payload type ID alongside the bytes so the caller +/// knows how to deserialize on the other side. +/// +/// Errors flow up so the FFI boundary can surface them as a synthetic +/// `PluginErrorRecord` in `result.errors` rather than silently dropping +/// a plugin's modification. Two failure modes: +/// - downcast didn't match any registered type (plugin returned a +/// payload not in the FFI registry) +/// - rmp_serde encoding failed (very unlikely for our known types, +/// but bubble it up rather than swallow it) +fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), String> { + // Try CMF MessagePayload first (most common) + if let Some(mp) = payload + .as_any() + .downcast_ref::() + { + return rmp_serde::to_vec_named(mp) + .map(|b| (PAYLOAD_CMF_MESSAGE, b)) + .map_err(|e| format!("CMF payload serialize failed: {e}")); + } + // Try GenericPayload + if let Some(gp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(&gp.value) + .map(|b| (PAYLOAD_GENERIC, b)) + .map_err(|e| format!("generic payload serialize failed: {e}")); + } + Err("unknown payload type, cannot serialize across FFI".to_string()) +} + +// --------------------------------------------------------------------------- +// Opaque Handle Types +// --------------------------------------------------------------------------- + +/// Opaque handle to a PluginManager. +/// +/// All managers share the process-singleton runtime returned by +/// `shared_runtime()` โ€” see the `SHARED_RUNTIME` doc-comment for why. +pub struct CpexManagerInner { + pub manager: PluginManager, +} + +/// Opaque handle to a ContextTable (Rust-owned, not serialized). +pub struct CpexContextTableInner { + table: PluginContextTable, +} + +/// Opaque handle to BackgroundTasks (Rust-owned, not serialized). +pub struct CpexBackgroundTasksInner { + tasks: BackgroundTasks, +} + +// --------------------------------------------------------------------------- +// Helper: safe string from C +// --------------------------------------------------------------------------- + +unsafe fn c_str_to_slice<'a>(ptr: *const c_char, len: c_int) -> Option<&'a str> { + if ptr.is_null() || len <= 0 { + return None; + } + let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); + std::str::from_utf8(bytes).ok() +} + +unsafe fn c_bytes_to_slice<'a>(ptr: *const u8, len: c_int) -> Option<&'a [u8]> { + if ptr.is_null() || len <= 0 { + return None; + } + Some(std::slice::from_raw_parts(ptr, len as usize)) +} + +/// Allocate a byte buffer and return it to the caller. +/// The caller must free it with `cpex_free_bytes`. +/// +/// Returns `(null, 0)` for empty input (`std::alloc::alloc` with size=0 +/// is UB per its docs) and for buffers that wouldn't fit in `c_int` โ€” +/// `c_int` is i32, so a buffer >= 2 GiB would silently truncate to a +/// negative length and `cpex_free_bytes` would dealloc with the wrong +/// size, corrupting the allocator. +fn alloc_bytes(data: &[u8]) -> (*mut u8, c_int) { + let len = data.len(); + if len == 0 { + return (ptr::null_mut(), 0); + } + if len > c_int::MAX as usize { + tracing::error!( + "alloc_bytes: payload size {} exceeds c_int::MAX ({}); refusing", + len, + c_int::MAX, + ); + return (ptr::null_mut(), 0); + } + let layout = std::alloc::Layout::from_size_align(len, 1).unwrap(); + unsafe { + let ptr = std::alloc::alloc(layout); + if ptr.is_null() { + return (ptr::null_mut(), 0); + } + std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, len); + (ptr, len as c_int) + } +} + +// --------------------------------------------------------------------------- +// Manager Lifecycle +// --------------------------------------------------------------------------- + +/// Create a new PluginManager from a YAML config string. +/// +/// Returns an opaque handle. The manager owns a tokio runtime for +/// async plugin execution. Returns NULL on failure. +/// +/// # Safety +/// `config_yaml` must be a valid pointer to `config_len` bytes of UTF-8. +#[no_mangle] +pub unsafe extern "C" fn cpex_manager_new( + config_yaml: *const c_char, + config_len: c_int, +) -> *mut CpexManagerInner { + let yaml = match c_str_to_slice(config_yaml, config_len) { + Some(s) => s, + None => return ptr::null_mut(), + }; + + let cpex_config = match cpex_core::config::parse_config(yaml) { + Ok(c) => c, + Err(e) => { + tracing::error!("cpex_manager_new: config parse failed: {}", e); + return ptr::null_mut(); + } + }; + + // Touch the shared runtime so any later cpex_configure_runtime + // call returns RC_INVALID_INPUT โ€” communicates "you missed the + // window" to the operator, instead of letting the configure call + // silently no-op. + let _ = shared_runtime(); + + let manager = PluginManager::default(); + + // Load config โ€” factories must be registered separately via cpex_register_factory + if let Err(e) = manager.load_config(cpex_config) { + tracing::error!("cpex_manager_new: load_config failed: {}", e); + return ptr::null_mut(); + } + + Box::into_raw(Box::new(CpexManagerInner { manager })) +} + +/// Create a new PluginManager with default config (no YAML). +/// +/// Useful when registering plugins programmatically. +#[no_mangle] +pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { + let _ = shared_runtime(); + let manager = PluginManager::default(); + Box::into_raw(Box::new(CpexManagerInner { manager })) +} + +/// Load a YAML config into an existing manager. +/// +/// Factories must be registered before calling this function. +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// `mgr` must be a valid handle. `config_yaml` must be valid UTF-8. +/// `mgr` is `*const` โ€” `PluginManager::load_config` takes `&self` after +/// the ArcSwap snapshot refactor (no exclusive access needed). Two +/// callers loading config concurrently is safe; the snapshot swap is +/// atomic copy-on-write, so they see consistent state per call. +#[no_mangle] +pub unsafe extern "C" fn cpex_load_config( + mgr: *const CpexManagerInner, + config_yaml: *const c_char, + config_len: c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let yaml = match c_str_to_slice(config_yaml, config_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + let cpex_config = match cpex_core::config::parse_config(yaml) { + Ok(c) => c, + Err(e) => { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } + }; + + // load_config is sync (no .await), but we still wrap in catch_unwind + // so a panic in serde / config validation doesn't unwind across FFI. + let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config))); + match load_result { + Ok(Ok(())) => RC_OK, + Ok(Err(e)) => { + tracing::error!("cpex_load_config: load_config failed: {}", e); + RC_PIPELINE_ERROR + } + Err(_panic) => { + tracing::error!("cpex_load_config: panic caught at FFI boundary"); + RC_PANIC + } + } +} + +/// Initialize all registered plugins. +/// +/// Returns 0 on success, -1 on failure (including timeout / panic). +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new`. +/// `mgr` is `*const` โ€” `PluginManager::initialize` takes `&self`. +#[no_mangle] +pub unsafe extern "C" fn cpex_initialize(mgr: *const CpexManagerInner) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + match run_safely(inner.manager.initialize(), "cpex_initialize") { + SafeRun::Ok(Ok(())) => RC_OK, + SafeRun::Ok(Err(e)) => { + tracing::error!("cpex_initialize: {}", e); + RC_PIPELINE_ERROR + } + other => other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + } +} + +/// Shutdown all plugins and free the manager. +/// +/// `mgr` stays `*mut` here because we consume the Box (this is the one +/// place we genuinely take exclusive ownership โ€” destroying the +/// allocation). All other entry points use `*const`. +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new`. After this +/// call, the handle is invalid and must not be used. +#[no_mangle] +pub unsafe extern "C" fn cpex_shutdown(mgr: *mut CpexManagerInner) { + if mgr.is_null() { + return; + } + let inner = Box::from_raw(mgr); + // Wrap shutdown in catch_unwind + timeout so a misbehaving plugin + // can't hang teardown forever or unwind across the FFI boundary. + // We don't have a return value here โ€” `inner` is dropped at function + // end either way, freeing the manager and runtime. + let _ = run_safely(inner.manager.shutdown(), "cpex_shutdown"); +} + +/// Check if any plugins are registered for a hook name. +/// +/// Returns 1 (true) or 0 (false). No serialization โ€” just a hash lookup. +/// +/// # Safety +/// `mgr` must be valid. `hook_name` must point to `hook_len` bytes of UTF-8. +#[no_mangle] +pub unsafe extern "C" fn cpex_has_hooks_for( + mgr: *const CpexManagerInner, + hook_name: *const c_char, + hook_len: c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return 0, + }; + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return 0, + }; + if inner.manager.has_hooks_for(name) { + 1 + } else { + 0 + } +} + +/// Get the number of registered plugins. +/// +/// No serialization โ€” returns an integer directly. +/// +/// # Safety +/// `mgr` must be valid. +#[no_mangle] +pub unsafe extern "C" fn cpex_plugin_count(mgr: *const CpexManagerInner) -> c_int { + match mgr.as_ref() { + Some(m) => m.manager.plugin_count() as c_int, + None => 0, + } +} + +/// Whether the manager has been initialized (i.e., `cpex_initialize` +/// returned successfully and `cpex_shutdown` has not been called). +/// +/// Returns 1 if initialized, 0 otherwise (including null mgr). +/// +/// # Safety +/// `mgr` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_is_initialized(mgr: *const CpexManagerInner) -> c_int { + match mgr.as_ref() { + Some(m) if m.manager.is_initialized() => 1, + _ => 0, + } +} + +/// Get the names of all registered plugins as MessagePack-encoded +/// `Vec`. Caller must free the returned bytes with +/// `cpex_free_bytes`. +/// +/// Returns an `RC_*` code; on success the names are written to +/// `*names_msgpack_out` / `*names_len_out`. +/// +/// # Safety +/// `mgr` must be valid. Output pointers must be writable. +#[no_mangle] +pub unsafe extern "C" fn cpex_plugin_names( + mgr: *const CpexManagerInner, + names_msgpack_out: *mut *mut u8, + names_len_out: *mut c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let names = inner.manager.plugin_names(); + let bytes = match rmp_serde::to_vec_named(&names) { + Ok(b) => b, + Err(_) => return RC_SERIALIZE_ERROR, + }; + let (ptr, len) = alloc_bytes(&bytes); + *names_msgpack_out = ptr; + *names_len_out = len; + RC_OK +} + +// --------------------------------------------------------------------------- +// Hook Invocation +// --------------------------------------------------------------------------- + +/// Invoke a hook by name. +/// +/// Payload and extensions are passed as MessagePack bytes. +/// ContextTable is an opaque handle (NULL for first invocation). +/// Returns MessagePack-encoded PipelineResult + opaque handles for +/// context table and background tasks. +/// +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// All pointer parameters must be valid or NULL where documented. +/// `mgr` is `*const` โ€” `PluginManager::invoke_by_name` takes `&self` +/// after the ArcSwap snapshot refactor. The previous `*mut` + `as_mut()` +/// shape produced aliased `&mut` references when two goroutines called +/// this concurrently โ€” UB regardless of what the called code did. The +/// `&self` API plus `*const` here is sound for parallel dispatch. +#[no_mangle] +pub unsafe extern "C" fn cpex_invoke( + mgr: *const CpexManagerInner, + hook_name: *const c_char, + hook_len: c_int, + payload_type: u8, + payload_msgpack: *const u8, + payload_len: c_int, + extensions_msgpack: *const u8, + extensions_len: c_int, + context_table: *mut CpexContextTableInner, // NULL for first call + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + context_table_out: *mut *mut CpexContextTableInner, + bg_handle_out: *mut *mut CpexBackgroundTasksInner, +) -> c_int { + // Validate manager handle + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + // Parse hook name + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + // Deserialize payload using the type registry + let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + + let payload: Box = match deserialize_payload(payload_type, payload_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!("cpex_invoke: {}", e); + return RC_PARSE_ERROR; + } + }; + + // Deserialize extensions from MessagePack + let extensions: Extensions = if extensions_len > 0 { + let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + match rmp_serde::from_slice(ext_bytes) { + Ok(e) => e, + Err(e) => { + tracing::error!("cpex_invoke: extensions deserialize failed: {}", e); + return RC_PARSE_ERROR; + } + } + } else { + Extensions::default() + }; + + // Get or create context table + let ctx_table: Option = if context_table.is_null() { + None + } else { + let ct = Box::from_raw(context_table); + Some(ct.table) + }; + + // Invoke the hook with wall-clock timeout + panic catch. + let (mut result, bg) = match run_safely( + inner + .manager + .invoke_by_name(name, payload, extensions, ctx_table), + "cpex_invoke", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + }; + + // Serialize modified payload using the type registry. A failure + // here is partial โ€” the rest of the result (continue_processing, + // violation, metadata, modified_extensions) is still valid โ€” so + // we surface the issue as a synthetic FFI-layer record in + // `result.errors` rather than failing the whole call. This is + // uniform with how the pipeline reports plugin-level errors + // swallowed by `on_error: ignore` / `on_error: disable`. + let (result_payload_type, modified_payload_bytes) = match result.modified_payload.as_ref() { + None => (payload_type, None), + Some(p) => match serialize_payload(p.as_ref()) { + Ok((t, b)) => (t, Some(b)), + Err(e) => { + tracing::warn!("cpex_invoke: dropped modified payload โ€” {}", e); + result.errors.push(cpex_core::error::PluginErrorRecord { + plugin_name: "".to_string(), + message: format!("modified payload could not be serialized across FFI: {e}"), + code: Some("ffi_serialize_error".to_string()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }); + (payload_type, None) + } + }, + }; + + // Serialize modified extensions if present + let modified_extensions_bytes: Option> = result + .modified_extensions + .as_ref() + .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + + // Build FFI result. `errors` flows through verbatim โ€” it's already + // PluginErrorRecord which is the canonical wire shape. + let ffi_result = FfiPipelineResult { + continue_processing: result.continue_processing, + violation: result.violation, + errors: result.errors, + metadata: result.metadata, + payload_type: result_payload_type, + modified_payload: modified_payload_bytes, + modified_extensions: modified_extensions_bytes, + }; + + let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { + Ok(b) => b, + Err(e) => { + tracing::error!("cpex_invoke: result serialize failed: {}", e); + return RC_SERIALIZE_ERROR; + } + }; + + // Return result bytes + let (ptr, len) = alloc_bytes(&result_bytes); + *result_msgpack_out = ptr; + *result_len_out = len; + + // Return context table as opaque handle + *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { + table: result.context_table, + })); + + // Return background tasks as opaque handle + *bg_handle_out = Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: bg })); + + RC_OK +} + +// --------------------------------------------------------------------------- +// Background Tasks +// --------------------------------------------------------------------------- + +/// Wait for all background tasks to complete. +/// +/// Returns MessagePack-encoded errors (empty array if none). +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// `bg_handle` must be a valid handle from `cpex_invoke`. +/// After this call, the handle is consumed and invalid. +/// `mgr` is `*const` โ€” only the runtime is borrowed (`&self`). +#[no_mangle] +pub unsafe extern "C" fn cpex_wait_background( + mgr: *const CpexManagerInner, + bg_handle: *mut CpexBackgroundTasksInner, + errors_msgpack_out: *mut *mut u8, + errors_len_out: *mut c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => { + // Consume `bg_handle` even on the failure path โ€” the Go + // caller has already nil'd its reference, so without + // dropping the Box here we'd leak the BackgroundTasks + // allocation (and its still-running task handles). + if !bg_handle.is_null() { + drop(Box::from_raw(bg_handle)); + } + return RC_INVALID_HANDLE; + } + }; + + if bg_handle.is_null() { + let empty: Vec = Vec::new(); + let (ptr, len) = alloc_bytes(&rmp_serde::to_vec_named(&empty).unwrap()); + *errors_msgpack_out = ptr; + *errors_len_out = len; + return RC_OK; + } + + let bg = Box::from_raw(bg_handle); + // `inner` is now unused but the borrow proved the manager is alive + // for the duration of this call (the read lock on the Go side). + let _ = inner; + let errors = match run_safely(bg.tasks.wait_for_background_tasks(), "cpex_wait_background") { + SafeRun::Ok(errs) => errs, + other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + }; + + // Flatten each Rust PluginError variant into the canonical wire + // shape so Go callers get structured fields (plugin_name, code, + // details) instead of a stringified Display impl. + let ffi_errors: Vec = errors + .iter() + .map(cpex_core::error::PluginErrorRecord::from) + .collect(); + let error_bytes = match rmp_serde::to_vec_named(&ffi_errors) { + Ok(b) => b, + Err(_) => return RC_SERIALIZE_ERROR, + }; + + let (ptr, len) = alloc_bytes(&error_bytes); + *errors_msgpack_out = ptr; + *errors_len_out = len; + + RC_OK +} + +/// Free a background tasks handle without waiting. +/// +/// Tasks continue running in the tokio runtime. +/// +/// # Safety +/// `bg_handle` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_background(bg_handle: *mut CpexBackgroundTasksInner) { + if !bg_handle.is_null() { + drop(Box::from_raw(bg_handle)); + } +} + +// --------------------------------------------------------------------------- +// Context Table +// --------------------------------------------------------------------------- + +/// Free a context table handle. +/// +/// # Safety +/// `ct` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_context_table(ct: *mut CpexContextTableInner) { + if !ct.is_null() { + drop(Box::from_raw(ct)); + } +} + +// --------------------------------------------------------------------------- +// Memory Management +// --------------------------------------------------------------------------- + +/// Free a byte buffer allocated by the FFI layer. +/// +/// # Safety +/// `ptr` must have been allocated by this library (from `cpex_invoke` +/// or `cpex_wait_background`). `len` must match the original allocation. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_bytes(ptr: *mut u8, len: c_int) { + if ptr.is_null() || len <= 0 { + return; + } + let layout = std::alloc::Layout::from_size_align(len as usize, 1).unwrap(); + std::alloc::dealloc(ptr, layout); +} + +// --------------------------------------------------------------------------- +// FFI Result Types โ€” serialized to MessagePack for the caller +// --------------------------------------------------------------------------- + +/// Pipeline result serialized across the FFI boundary. +/// Matches the Go `PipelineResult` struct field names. +/// +/// `errors` carries records from `on_error: ignore` / `on_error: disable` +/// plugins so the Go caller can surface them programmatically rather +/// than parsing log output. Fire-and-forget errors come through +/// `BackgroundTasks::wait_for_background_tasks()` instead. +#[derive(serde::Serialize, serde::Deserialize)] +struct FfiPipelineResult { + continue_processing: bool, + #[serde(skip_serializing_if = "Option::is_none")] + violation: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + errors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + /// Payload type ID โ€” tells the Go caller how to deserialize. + payload_type: u8, + /// Modified payload as raw MessagePack bytes (if a plugin modified it). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "serde_bytes_opt")] + modified_payload: Option>, + /// Modified extensions as raw MessagePack bytes (if a plugin modified them). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "serde_bytes_opt")] + modified_extensions: Option>, +} + +/// Helper for serializing Option> as binary in MessagePack. +mod serde_bytes_opt { + use serde::{Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(bytes) => serde::Serialize::serialize(&serde_bytes::Bytes::new(bytes), s), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + use serde::Deserialize; + Option::::deserialize(d).map(|o| o.map(|b| b.into_vec())) + } +} + +// --------------------------------------------------------------------------- +// Generic Payload โ€” wraps a deserialized MessagePack value +// --------------------------------------------------------------------------- + +/// A generic payload that wraps a deserialized serde_json::Value. +/// +/// Used for FFI dispatch when the concrete payload type isn't known +/// at compile time. The value was deserialized from MessagePack on +/// the Go side and will be passed to Rust plugins as-is. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenericPayload { + pub value: serde_json::Value, +} + +cpex_core::impl_plugin_payload!(GenericPayload); + +// --------------------------------------------------------------------------- +// FFI unit tests +// --------------------------------------------------------------------------- +// +// These tests call the `extern "C"` functions directly from Rust to +// exercise the FFI safety layer (catch_unwind, return-code mapping, +// payload-type validation) without needing a Go test harness. The +// reviewer flagged that `cpex-ffi` had zero `#[cfg(test)]` coverage โ€” +// this seeds the file with regressions for the highest-value invariants. + +#[cfg(test)] +mod tests { + use super::*; + use std::ptr; + use std::sync::Arc; + + use async_trait::async_trait; + use cpex_core::hooks::payload::Extensions; + use cpex_core::hooks::trait_def::HookTypeDef; + use cpex_core::hooks::PluginResult; + use cpex_core::plugin::{Plugin, PluginConfig, PluginMode}; + + // --- Test scaffolding ----------------------------------------------------- + + /// Test hook type using GenericPayload โ€” that's the type + /// PAYLOAD_GENERIC produces at the FFI deserialization boundary, + /// so the typed-adapter downcast actually finds the handler. + /// (Defining a custom TestPayload would mean the executor's + /// downcast finds None and the handler never runs.) + struct TestHook; + impl HookTypeDef for TestHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "test_hook"; + } + + /// A plugin whose handler always panics โ€” exercises the `catch_unwind` + /// path inside `run_safely`. + struct PanickingPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for PanickingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + impl cpex_core::hooks::HookHandler for PanickingPlugin { + async fn handle( + &self, + _payload: &GenericPayload, + _extensions: &Extensions, + _ctx: &mut cpex_core::context::PluginContext, + ) -> PluginResult { + panic!("simulated panic from PanickingPlugin"); + } + } + + /// Build an FFI-shaped manager for testing. Bypasses + /// `cpex_manager_new` so we can register Rust plugins directly via + /// the manager's typed API. + fn build_test_manager() -> *mut CpexManagerInner { + // Touch the shared runtime so it's initialized; tests use it + // rather than a per-manager runtime. + let _ = shared_runtime(); + let manager = cpex_core::manager::PluginManager::default(); + Box::into_raw(Box::new(CpexManagerInner { manager })) + } + + fn register_panicking_plugin(mgr: &CpexManagerInner) { + let cfg = PluginConfig { + name: "panicker".into(), + kind: "test".into(), + hooks: vec!["test_hook".into()], + mode: PluginMode::Sequential, + ..Default::default() + }; + let plugin = Arc::new(PanickingPlugin { cfg: cfg.clone() }); + mgr.manager + .register_handler::(plugin, cfg) + .expect("register"); + } + + /// Encode a generic JSON value to MessagePack, the wire format + /// PAYLOAD_GENERIC consumes. Returns bytes the FFI can borrow. + fn payload_bytes(value: &str) -> Vec { + rmp_serde::to_vec_named(&serde_json::json!({ "value": value })).expect("encode payload") + } + + /// Drive cpex_invoke with a single hook name and the given payload. + /// Returns the raw rc; output buffers are dropped. + unsafe fn invoke_for_test( + mgr: *const CpexManagerInner, + payload_type: u8, + payload: &[u8], + ) -> c_int { + let hook_name = b"test_hook"; + let mut result_ptr: *mut u8 = ptr::null_mut(); + let mut result_len: c_int = 0; + let mut ct_out: *mut CpexContextTableInner = ptr::null_mut(); + let mut bg_out: *mut CpexBackgroundTasksInner = ptr::null_mut(); + + let rc = cpex_invoke( + mgr, + hook_name.as_ptr() as *const c_char, + hook_name.len() as c_int, + payload_type, + payload.as_ptr(), + payload.len() as c_int, + ptr::null(), + 0, + ptr::null_mut(), + &mut result_ptr, + &mut result_len, + &mut ct_out, + &mut bg_out, + ); + + // Drain any output buffers / handles to avoid leaks across tests. + if !result_ptr.is_null() { + cpex_free_bytes(result_ptr, result_len); + } + if !ct_out.is_null() { + cpex_free_context_table(ct_out); + } + if !bg_out.is_null() { + cpex_free_background(bg_out); + } + rc + } + + // --- Tests ---------------------------------------------------------------- + + /// Panic in a plugin must be caught at the FFI boundary and mapped + /// to `RC_PANIC` rather than unwinding across `extern "C"` (UB on + /// Rust < 1.81; abort on >= 1.81). Direct regression for P0 #2. + #[test] + fn cpex_invoke_returns_rc_panic_when_plugin_panics() { + let mgr = build_test_manager(); + // Defer cleanup so a test failure doesn't leak the manager. + struct ManagerGuard(*mut CpexManagerInner); + impl Drop for ManagerGuard { + fn drop(&mut self) { + unsafe { + cpex_shutdown(self.0); + } + } + } + let _guard = ManagerGuard(mgr); + + unsafe { + let inner = &*mgr; + register_panicking_plugin(inner); + // Manager must be initialized for the pipeline to dispatch. + let init_rc = cpex_initialize(mgr); + assert_eq!(init_rc, RC_OK, "init should succeed"); + + // Invoke with the registered hook โ€” plugin panics, caught + // by run_safely's catch_unwind, mapped to RC_PANIC. + let bytes = payload_bytes("trigger"); + let rc = invoke_for_test(mgr, PAYLOAD_GENERIC, &bytes); + assert_eq!( + rc, RC_PANIC, + "panic should be caught and surfaced as RC_PANIC, got {}", + rc, + ); + } + } + + /// Invoking with an unknown `payload_type` must return + /// `RC_PARSE_ERROR` โ€” the deserialize_payload registry rejects + /// unknown discriminators with a typed error code rather than a + /// generic failure. + #[test] + fn cpex_invoke_returns_rc_parse_error_on_unknown_payload_type() { + let mgr = build_test_manager(); + struct ManagerGuard(*mut CpexManagerInner); + impl Drop for ManagerGuard { + fn drop(&mut self) { + unsafe { + cpex_shutdown(self.0); + } + } + } + let _guard = ManagerGuard(mgr); + + unsafe { + let inner = &*mgr; + register_panicking_plugin(inner); // need *some* plugin so dispatch runs + assert_eq!(cpex_initialize(mgr), RC_OK); + + // Unknown payload type โ€” dispatch never reaches the plugin. + let bytes = payload_bytes("trigger"); + let rc = invoke_for_test(mgr, 99 /* not in registry */, &bytes); + assert_eq!( + rc, RC_PARSE_ERROR, + "unknown payload_type should map to RC_PARSE_ERROR, got {}", + rc, + ); + } + } + + /// `worker_threads_from_env` parses CPEX_FFI_WORKER_THREADS into + /// a positive count, returning None for unset / zero / negative / + /// unparseable. This isolates the env-parsing logic from the + /// OnceLock-init path so we can test it deterministically. + #[test] + fn worker_threads_from_env_parses_correctly() { + // Use a unique env var name per test invocation isn't possible + // (the function reads ENV_WORKER_THREADS specifically), so we + // serialize manipulation: set, read, restore. Run-time tests + // don't currently parallelize this var across threads. + let prev = std::env::var(ENV_WORKER_THREADS).ok(); + + // SAFETY: tests are single-threaded with respect to this env + // var (no other test reads/writes it). std::env::set_var is + // unsafe in multi-threaded programs reading other env vars + // concurrently; we accept that risk in the test harness. + let restore = |v: Option| unsafe { + match v { + Some(s) => std::env::set_var(ENV_WORKER_THREADS, s), + None => std::env::remove_var(ENV_WORKER_THREADS), + } + }; + + unsafe { + std::env::set_var(ENV_WORKER_THREADS, "8"); + assert_eq!(worker_threads_from_env(), Some(8)); + + std::env::set_var(ENV_WORKER_THREADS, "0"); + assert_eq!(worker_threads_from_env(), None, "zero should fall back"); + + std::env::set_var(ENV_WORKER_THREADS, "garbage"); + assert_eq!( + worker_threads_from_env(), + None, + "unparseable should fall back" + ); + + std::env::remove_var(ENV_WORKER_THREADS); + assert_eq!(worker_threads_from_env(), None, "unset should be None"); + } + + restore(prev); + } + + /// `cpex_configure_runtime` rejects non-positive worker counts + /// before touching the shared runtime โ€” the early bounds check + /// fires regardless of OnceLock state, so this is order-independent. + #[test] + fn cpex_configure_runtime_rejects_non_positive_workers() { + assert_eq!(cpex_configure_runtime(0), RC_INVALID_INPUT); + assert_eq!(cpex_configure_runtime(-1), RC_INVALID_INPUT); + } + + /// Once the shared runtime is initialized (e.g., by any prior test + /// or `cpex_manager_new` call), subsequent configure attempts must + /// fail with `RC_INVALID_INPUT` โ€” the runtime is single-init. + #[test] + fn cpex_configure_runtime_after_init_returns_invalid_input() { + // Touch the runtime to ensure it's initialized. This may already + // have happened in another test; either way OnceLock is set. + let _ = shared_runtime(); + // Configure should now refuse: window has closed. + assert_eq!(cpex_configure_runtime(2), RC_INVALID_INPUT); + } + + /// `serialize_payload` returns `Ok` for known registered types so + /// modifications round-trip cleanly. + #[test] + fn serialize_payload_round_trips_generic() { + let gp = GenericPayload { + value: serde_json::json!({ "k": "v" }), + }; + let (t, bytes) = serialize_payload(&gp).expect("known type should serialize"); + assert_eq!(t, PAYLOAD_GENERIC); + // Confirm the encoded bytes deserialize back to the same shape + // โ€” guards against silent type-id/wire-format drift. + let value: serde_json::Value = rmp_serde::from_slice(&bytes).expect("round-trip decode"); + assert_eq!(value, serde_json::json!({ "k": "v" })); + } + + /// `serialize_payload` returns `Err` for payload types the FFI + /// registry doesn't know about. Without this contract the FFI + /// silently dropped a plugin's modification โ€” the caller saw + /// `modified_payload = None` even though one was produced. + /// This test pins the new error contract so that regression can't + /// reappear. + #[test] + fn serialize_payload_returns_err_for_unknown_type() { + // A custom PluginPayload impl that's not in the FFI registry โ€” + // simulates a plugin returning a custom payload type the + // serializer doesn't know how to ship across the wire. + #[derive(Clone)] + struct CustomPayload; + impl PluginPayload for CustomPayload { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + } + let err = serialize_payload(&CustomPayload).expect_err("unknown type should err"); + assert!( + err.contains("unknown payload type"), + "error message should identify the failure mode, got: {err}", + ); + } + + /// A null manager handle must return `RC_INVALID_HANDLE` from + /// every entry point โ€” guards the `as_ref()` precondition. + #[test] + fn cpex_invoke_returns_rc_invalid_handle_on_null_mgr() { + unsafe { + let bytes = payload_bytes("x"); + let rc = invoke_for_test(ptr::null(), PAYLOAD_GENERIC, &bytes); + assert_eq!(rc, RC_INVALID_HANDLE); + + assert_eq!(cpex_initialize(ptr::null()), RC_INVALID_HANDLE); + assert_eq!(cpex_is_initialized(ptr::null()), 0); + } + } +} diff --git a/crates/cpex-sdk/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..e4ef19f4 --- /dev/null +++ b/crates/cpex-sdk/src/lib.rs @@ -0,0 +1,51 @@ +// Location: ./crates/cpex-sdk/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX SDK โ€” lean crate for plugin authors. +// +// Re-exports the Plugin trait and supporting types from cpex-core. +// Plugin authors depend on this crate instead of the full runtime, +// keeping their dependency tree minimal. This is also the crate +// that WASM plugins compile against. + +// Plugin lifecycle +pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// Hook system +pub use cpex_core::hooks::{Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult}; + +// Context +pub use cpex_core::context::PluginContext; + +// Errors +pub use cpex_core::error::{PluginError, PluginViolation}; + +// Re-export the define_hook! macro +pub use cpex_core::define_hook; + +// CMF types +pub use cpex_core::cmf::{ + // Content parts and domain objects + AudioSource, + // Enums + Channel, + // Message and payload + CmfHook, + ContentPart, + ContentType, + DocumentSource, + ImageSource, + Message, + MessagePayload, + PromptRequest, + PromptResult, + Resource, + ResourceReference, + ResourceType, + Role, + ToolCall, + ToolResult, + VideoSource, +}; diff --git a/docs/specs/cpex-go-spec.md b/docs/specs/cpex-go-spec.md new file mode 100644 index 00000000..55d21757 --- /dev/null +++ b/docs/specs/cpex-go-spec.md @@ -0,0 +1,1107 @@ +# CPEX Go โ€” Public API Specification + +**Status**: Draft +**Date**: May 2026 +**Source**: `github.com/contextforge-org/contextforge-plugins-framework/go/cpex` + +CPEX Go is the Golang consumption API for the ContextForge Plugin Extension Framework (CPEX). It embeds the Rust plugin runtime in-process via CGo/FFI, providing Go host systems with a high-performance hook-based extensibility layer. Payloads and extensions cross the FFI boundary as MessagePack bytes; plugin execution happens entirely in the Rust async runtime. + +## 1. Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Go Host (e.g., AuthBridge) โ”‚ +โ”‚ โ”‚ +โ”‚ PluginManager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ NewPluginManager[Default]() โ”‚ โ”‚ +โ”‚ โ”‚ RegisterFactories(fn) โ”‚ โ”‚ +โ”‚ โ”‚ LoadConfig(yaml) โ”‚ โ”‚ +โ”‚ โ”‚ Initialize() โ”‚ โ”‚ +โ”‚ โ”‚ InvokeByName(hook, payload, ext, ctx) โ”‚ โ”‚ +โ”‚ โ”‚ Invoke[P](hook, payload, ext, ctx) โ”‚ โ”‚ +โ”‚ โ”‚ HasHooksFor(hook) / PluginCount() โ”‚ โ”‚ +โ”‚ โ”‚ Shutdown() โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ CGo / MessagePack โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ libcpex_ffi (Rust) โ–ผ โ”‚ +โ”‚ cpex_manager_new / cpex_invoke / cpex_shutdown โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ cpex-core (Rust) โ”‚ โ”‚ +โ”‚ โ”‚ โ€ข PluginManager โ†’ Executor โ†’ Plugins โ”‚ โ”‚ +โ”‚ โ”‚ โ€ข tokio runtime (async plugin execution) โ”‚ โ”‚ +โ”‚ โ”‚ โ€ข Phase ordering, capability gating โ”‚ โ”‚ +โ”‚ โ”‚ โ€ข Route resolution, policy composition โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Key design decisions:** + +- Plugins are written in Rust (native) and compiled into `libcpex_ffi`. The Go layer is the host embedding API, not a plugin authoring API. +- The FFI boundary uses MessagePack for payloads/extensions and opaque handles for stateful objects (ContextTable, BackgroundTasks). +- All `PluginManager`s in a process share a single tokio runtime (process-singleton via `OnceLock`). Async plugin execution works from synchronous CGo calls without exploding thread count under multi-tenant hosts. Worker thread count is configurable โ€” see ยง5.8. + +## 2. Package & Import + +```go +import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +``` + +**Dependencies:** + +| Dependency | Purpose | +|---|---| +| `github.com/vmihailenco/msgpack/v5` | MessagePack serialization across FFI | + +**Build requirements:** + +```bash +# Build the Rust FFI library first +cargo build --release -p cpex-ffi + +# Then build/test Go code +go test -v ./... +``` + +CGo links against `libcpex_ffi` from `target/release/`. + +## 3. Lifecycle + +``` +[ConfigureRuntime(N)] โ† optional, package-level, before any manager + โ”‚ + โ–ผ +NewPluginManagerDefault() + โ”‚ + โ–ผ +RegisterFactories(fn) โ† register Rust plugin factories via callback + โ”‚ + โ–ผ +LoadConfig(yaml) โ† YAML with plugin definitions, routing, policies + โ”‚ + โ–ผ +Initialize() โ† instantiate and wire all plugins + โ”‚ + โ–ผ +InvokeByName / Invoke[P] โ† dispatch hooks (repeatable) + โ”‚ + โ–ผ +Shutdown() โ† graceful teardown +``` + +## 4. Quick Reference + +| Operation | Method | +|---|---| +| Configure runtime (optional) | `cpex.ConfigureRuntime(workerThreads)` | +| Create manager | `NewPluginManagerDefault()` or `NewPluginManager(yaml)` | +| Register factories | `mgr.RegisterFactories(fn)` | +| Load config | `mgr.LoadConfig(yaml)` | +| Initialize | `mgr.Initialize()` | +| Query lifecycle | `mgr.IsInitialized()` | +| Check hooks exist | `mgr.HasHooksFor(hookName)` | +| Count plugins | `mgr.PluginCount()` | +| List plugins | `mgr.PluginNames()` | +| Invoke (untyped) | `mgr.InvokeByName(hook, type, payload, ext, ctx)` | +| Invoke (typed) | `Invoke[P](mgr, hook, type, payload, ext, ctx)` | +| Check denial | `result.IsDenied()` | +| Get violation | `result.Violation` | +| Get pipeline errors | `result.Errors` (from `on_error: ignore`/`disable` plugins) | +| Thread context | Pass returned `*ContextTable` to next invoke | +| Wait background | `bg.Wait()` returns `([]PluginError, error)` | +| Release background | `bg.Close()` | +| Classify error | `errors.Is(err, ErrCpexTimeout)` (and other sentinels โ€” ยง14) | +| Shutdown | `mgr.Shutdown()` | + +## 5. Core Types + +### 5.1 PluginManager + +The top-level object. Owns the Rust runtime and plugin registry. + +```go +type PluginManager struct { /* opaque CGo handle, sync.RWMutex */ } + +// Construction +func NewPluginManager(yaml string) (*PluginManager, error) +func NewPluginManagerDefault() (*PluginManager, error) + +// Factory registration +func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error + +// Configuration +func (m *PluginManager) LoadConfig(yaml string) error + +// Initialization +func (m *PluginManager) Initialize() error + +// Query +func (m *PluginManager) HasHooksFor(hookName string) bool +func (m *PluginManager) PluginCount() int +func (m *PluginManager) IsInitialized() bool +func (m *PluginManager) PluginNames() ([]string, error) + +// Invocation +func (m *PluginManager) InvokeByName( + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) + +// Typed invocation (generics) +func Invoke[P any]( + m *PluginManager, + hookName string, + payloadType uint8, + payload P, + extensions *Extensions, + contextTable *ContextTable, +) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) + +// Teardown +func (m *PluginManager) Shutdown() +``` + +**Notes:** +- `NewPluginManager(yaml)` creates the manager AND loads config in one call (factories auto-registered). +- `NewPluginManagerDefault()` creates an empty manager โ€” call `RegisterFactories` then `LoadConfig` separately. +- A Go finalizer calls `Shutdown()` if the caller forgets, but explicit `Shutdown()` is recommended. +- The `PluginManager` wrapper holds a `sync.RWMutex` so `Shutdown()` cannot race with concurrent `Invoke*` calls; Operation methods take the read lock, lifecycle methods take the write lock. +- `PluginNames()` returns the registered plugin names in registration order (no guaranteed sort). + +### 5.2 FactoryRegistrar + +```go +type FactoryRegistrar func(handle unsafe.Pointer) error +``` + +A callback that receives the raw C manager handle. The caller uses this to invoke their own `extern "C"` factory registration function. This is the bridge for registering custom Rust plugin factories that are compiled into a separate shared library. + +**Example:** + +```go +/* +#include +int my_register_factories(void* mgr); +*/ +import "C" + +err := mgr.RegisterFactories(func(handle unsafe.Pointer) error { + rc := C.my_register_factories(handle) + if rc != 0 { + return fmt.Errorf("factory registration failed: %d", rc) + } + return nil +}) +``` + +### 5.3 ContextTable + +```go +type ContextTable struct { /* opaque CGo handle */ } +func (ct *ContextTable) Close() +``` + +Per-plugin state that persists across hook invocations within a single request. Thread the returned `ContextTable` from one `Invoke` call into the next to maintain plugin-local context. + +- Pass `nil` on the first invocation. +- After use, the handle is consumed by the next `Invoke` call (ownership transfers to Rust). +- Call `Close()` to release without further use. + +### 5.4 BackgroundTasks + +```go +type BackgroundTasks struct { /* opaque CGo handle */ } +func (bg *BackgroundTasks) Wait() ([]PluginError, error) +func (bg *BackgroundTasks) Close() +``` + +Handle to fire-and-forget tasks spawned by plugins (e.g., async audit logging). Tasks run in the shared Rust tokio runtime outside the request's latency budget. + +- `Wait()` blocks until all background tasks complete. Returns a structured `[]PluginError` from any that failed (typed shape โ€” `PluginName`, `Code`, `Message`, etc.; see ยง5.7) plus an `error` for FFI-level failures (e.g., the manager was shut down between invoke and wait โ€” returns `ErrCpexInvalidHandle`). +- `Close()` releases the handle without waiting โ€” tasks continue running. +- The handle holds a `*PluginManager` reference and checks `mgr.handle != nil` under the manager's lock before calling into Rust, so `Wait()` after `Shutdown()` is safe (returns `ErrCpexInvalidHandle` rather than dereferencing freed memory). + +### 5.5 PipelineResult + +```go +type PipelineResult struct { + ContinueProcessing bool + Violation *PluginViolation + Metadata map[string]any + PayloadType uint8 + ModifiedPayload []byte // raw MessagePack + ModifiedExtensions []byte // raw MessagePack + Errors []PluginError // see ยง5.7 +} + +func (r *PipelineResult) IsDenied() bool +func (r *PipelineResult) DeserializeExtensions() (*Extensions, error) +func DeserializePayload[T any](result *PipelineResult) (*T, error) +``` + +`Errors` carries structured records for failures from plugins that ran with `on_error: ignore` or `on_error: disable` โ€” previously these were only logged and invisible to callers. Use them to drive retry logic, dashboards, or audit trails. See ยง13.6 for the consumption pattern, and ยง5.7 for the synthetic FFI-layer record. + +### 5.6 TypedPipelineResult + +```go +type TypedPipelineResult[P any] struct { + ContinueProcessing bool + Violation *PluginViolation + Metadata map[string]any + PayloadType uint8 + ModifiedPayload *P + ModifiedExtensions *Extensions + Errors []PluginError +} + +func (r *TypedPipelineResult[P]) IsDenied() bool +``` + +The typed invoke path (`Invoke[P]`) automatically deserializes the modified payload and extensions into concrete Go types. `Errors` is the same shape as `PipelineResult.Errors`. + +### 5.7 PluginError + +```go +type PluginError struct { + PluginName string `msgpack:"plugin_name"` + Message string `msgpack:"message"` + Code string `msgpack:"code,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} +``` + +Structured plugin failure record. Used by `PipelineResult.Errors`, `TypedPipelineResult[P].Errors`, and `BackgroundTasks.Wait()`. All entries are framework-emitted โ€” plugins influence the record (via the error they return) but cannot forge `PluginName`, which is set by the executor from the registered plugin metadata. + +**Reserved synthetic plugin names:** + +| `PluginName` | Source | +|---|---| +| `` | Framework-emitted at the FFI boundary. Currently issued when a plugin's modified payload cannot be re-serialized across the wire (`Code: "ffi_serialize_error"`). The rest of the result remains valid; the failure is surfaced via `Errors` rather than failing the whole call. | + +Filter or branch by `PluginName == ""` if your host wants to distinguish FFI-layer failures from plugin-emitted failures. + +### 5.8 PluginViolation + +```go +type PluginViolation struct { + Code string + Reason string + Description string + Details map[string]any + PluginName string + ProtoErrorCode *int64 +} +``` + +Structured denial. `Code` is a machine-readable identifier; `Reason` is a short human-readable explanation. + +### 5.9 ConfigureRuntime (package-level) + +```go +func ConfigureRuntime(workerThreads int) error +``` + +Sets the worker thread count for the shared tokio runtime that backs every `PluginManager` in the process. **Must** be called before the first `NewPluginManager*` โ€” once a manager has been created the runtime is fixed for process lifetime. + +```go +// In main(), before any manager construction: +if err := cpex.ConfigureRuntime(8); err != nil { + log.Fatal(err) // returns ErrCpexInvalidInput on <=0 or after init +} +``` + +**Precedence (highest first):** + +1. `ConfigureRuntime(N)` โ€” explicit FFI call, before first use. +2. `CPEX_FFI_WORKER_THREADS` env var โ€” operator-friendly default. Read once on lazy init. +3. tokio default (`num_cpus`) โ€” when neither knob is set. + +Use case: multi-tenant hosts that want to bound total worker threads regardless of how many `PluginManager`s are alive (one per tenant, dynamic plugin reload, etc.). Without this knob, N managers ร— `num_cpus` workers each can blow up the OS thread count. + +## 6. Extensions + +Extensions carry capability-gated metadata alongside the payload. Each plugin sees only the extensions its declared capabilities grant. Serialized as MessagePack across the FFI boundary. + +```go +type Extensions struct { + Meta *MetaExtension + Security *SecurityExtension + Http *HttpExtension + Delegation *DelegationExtension + Agent *AgentExtension + Request *RequestExtension + MCP *MCPExtension + Completion *CompletionExtension + Provenance *ProvenanceExtension + LLM *LLMExtension + Framework *FrameworkExtension + Custom map[string]any +} +``` + +### 6.1 Extension Types + +| Extension | Purpose | Key Fields | +|---|---|---| +| `Meta` | Entity identification for route resolution | `EntityType`, `EntityName`, `Tags`, `Scope`, `Properties` | +| `Security` | Identity, labels, data policies | `Subject`, `Agent`, `Labels`, `Classification`, `AuthMethod`, `Objects`, `Data` | +| `Http` | HTTP request/response context | `RequestHeaders`, `ResponseHeaders` | +| `Delegation` | Token delegation chain | `Chain[]`, `Depth`, `OriginSubjectID`, `ActorSubjectID` | +| `Agent` | Agent execution context | `Input`, `SessionID`, `ConversationID`, `Turn` (`*uint32`), `AgentID`, `ParentAgentID`, `Conversation` (`*ConversationContext`) | +| `Request` | Execution environment and tracing | `Environment`, `RequestID`, `TraceID`, `SpanID`, `Timestamp` | +| `MCP` | MCP entity metadata | `Tool`, `Resource`, `Prompt` | +| `Completion` | LLM completion stats | `StopReason`, `Tokens`, `Model`, `RawFormat`, `CreatedAt`, `LatencyMs` | +| `Provenance` | Origin and message threading | `Source`, `MessageID`, `ParentID` | +| `LLM` | Model identity | `ModelID`, `Provider`, `Capabilities` | +| `Framework` | Agentic framework context | `Framework`, `FrameworkVersion`, `NodeID`, `GraphID` | +| `Custom` | Arbitrary key-value pairs | `map[string]any` | + +### 6.2 Security Extension Detail + +```go +type SecurityExtension struct { + Labels []string + Classification string + Subject *SubjectExtension // authenticated caller + Agent *AgentIdentity // this agent's workload identity + AuthMethod string + Objects map[string]ObjectSecurityProfile + Data map[string]DataPolicy +} + +type SubjectExtension struct { + ID, SubjectType string + Roles, Permissions, Teams []string + Claims map[string]string +} + +type AgentIdentity struct { + ClientID, WorkloadID, TrustDomain string +} +``` + +### 6.3 Delegation Extension Detail + +```go +type DelegationExtension struct { + Chain []DelegationHop + Depth int + OriginSubjectID string + ActorSubjectID string + Delegated bool + AgeSeconds float64 +} + +type DelegationHop struct { + SubjectID, SubjectType, Audience, Strategy, Timestamp string + ScopesGranted []string + TTLSeconds *uint64 + FromCache bool +} +``` + +### 6.4 Capability-Gated Writes (Rust Plugin Side) + +The `capabilities` list in a plugin's YAML config controls which extension fields the plugin can read **and** write. The Rust executor translates declared capabilities into write tokens before calling `Plugin::handle`. A plugin that lacks `write_headers`, for example, receives `http_write_token: None` and cannot modify `HttpExtension`. + +The Rust write pattern uses COW (copy-on-write) ownership: + +```rust +// In Plugin::handle โ€” capability-gated extension modification +let mut owned = extensions.cow_copy(); // clone mutable slots + +if let Some(ref token) = owned.http_write_token { // token present iff capability declared + if let Some(http) = owned.http.as_mut() { + let h = http.write(token); + h.set_response_header("X-Tool-Name", name); + h.set_response_header("X-CPEX-Processed", "true"); + } +} + +PluginResult::modify_extensions(owned) // emit modified extensions back to Go +``` + +On the Go side, `result.ModifiedExtensions` (or `typed.ModifiedExtensions`) carries the updated extensions returned by the plugin. The Go caller can deserialize them with `result.DeserializeExtensions()` (see ยง13.3). + +**Rust `PluginResult` constructors:** + +| Constructor | What it signals | +|---|---| +| `PluginResult::allow()` | Pass, no changes | +| `PluginResult::deny(violation)` | Halt pipeline, return violation to Go | +| `PluginResult::modify_extensions(owned)` | Pass, return modified extensions | +| `PluginResult::modify_payload(payload)` | Pass, return modified payload | + +## 7. Payload Types + +### 7.1 Payload Type Registry + +CPEX uses a `payloadType` discriminator to tell the Rust core how to deserialize the payload: + +| Constant | Value | Payload Type | +|---|---|---| +| `PayloadGeneric` | `0` | `map[string]any` โ€” untyped JSON-like payload | +| `PayloadCMFMessage` | `1` | `MessagePayload` โ€” CMF message | + +Hosts define their own payload structs (e.g., `InboundPreValidationPayload`) and serialize them as `PayloadGeneric`. The type ID tells Rust how to deserialize; Go callers choose the ID and matching struct. + +### 7.2 Generic Payload + +Any `map[string]any` or struct with msgpack tags. Serialized as MessagePack, deserialized in Rust as a `serde_json::Value`. + +```go +payload := map[string]any{ + "tool_name": "get_compensation", + "user": "alice", +} +result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", cpex.PayloadGeneric, payload, ext, nil) +``` + +### 7.3 CMF MessagePayload + +The ContextForge Message Format โ€” a typed, multi-part message with schema versioning. + +```go +type MessagePayload struct { + Message Message `msgpack:"message"` +} + +type Message struct { + SchemaVersion string `msgpack:"schema_version"` + Role string `msgpack:"role"` + Content []ContentPart `msgpack:"content"` + Channel string `msgpack:"channel,omitempty"` +} + +func NewMessage(role string, content ...ContentPart) Message +``` + +### 7.4 Content Parts + +`ContentPart` is a tagged union discriminated by `content_type`. Custom msgpack encoding produces the same wire format as Rust's `#[serde(tag = "content_type")]`. + +| Content Type | Constructor | Data Field | +|---|---|---| +| `text` | `NewTextPart(s)` | `.Text` | +| `thinking` | `NewThinkingPart(s)` | `.Text` | +| `tool_call` | `NewToolCallPart(tc)` | `.ToolCallContent` | +| `tool_result` | `NewToolResultPart(tr)` | `.ToolResultContent` | +| `resource` | `NewResourcePart(r)` | `.ResourceContent` | +| `resource_ref` | `NewResourceRefPart(r)` | `.ResourceRefContent` | +| `prompt_request` | `NewPromptRequestPart(pr)` | `.PromptRequestContent` | +| `prompt_result` | `NewPromptResultPart(pr)` | `.PromptResultContent` | +| `image` | `NewImagePart(img)` | `.ImageContent` | +| `video` | `NewVideoPart(vid)` | `.VideoContent` | +| `audio` | `NewAudioPart(aud)` | `.AudioContent` | +| `document` | `NewDocumentPart(doc)` | `.DocumentContent` | + +Constructors follow Go's `NewXyz` convention so they don't shadow the like-named struct fields on `ContentPart` (e.g., the `ToolCallContent *ToolCall` field vs the `NewToolCallPart` constructor). + +Unknown `content_type` discriminators are preserved on decode via an internal `rawMap` and re-emitted unchanged on encode โ€” so a Go host running an older SDK against a newer Rust runtime won't silently drop content parts it doesn't recognize. + +**Example:** + +```go +msg := cpex.MessagePayload{ + Message: cpex.NewMessage("assistant", + cpex.NewTextPart("Looking up compensation data"), + cpex.NewToolCallPart(cpex.ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), +} + +result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( + mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, msg, ext, nil, +) +``` + +## 8. Hook Types (Built-in) + +Hooks are open strings โ€” hosts define their own. The following are built into `cpex-core`: + +### 8.1 Legacy Hooks (typed payloads) + +| Hook Name | Lifecycle Stage | +|---|---| +| `tool_pre_invoke` | Before tool execution | +| `tool_post_invoke` | After tool execution | +| `prompt_pre_fetch` | Before prompt template fetch | +| `prompt_post_fetch` | After prompt template fetch | +| `resource_pre_fetch` | Before resource fetch | +| `resource_post_fetch` | After resource fetch | +| `identity_resolve` | Identity resolution | +| `token_delegate` | Token delegation | + +### 8.2 CMF Hooks (MessagePayload) + +| Hook Name | Lifecycle Stage | +|---|---| +| `cmf.tool_pre_invoke` | Before tool execution (CMF message) | +| `cmf.tool_post_invoke` | After tool execution (CMF message) | +| `cmf.llm_input` | Before LLM call | +| `cmf.llm_output` | After LLM response | +| `cmf.prompt_pre_fetch` | Before prompt fetch (CMF) | +| `cmf.prompt_post_fetch` | After prompt fetch (CMF) | +| `cmf.resource_pre_fetch` | Before resource fetch (CMF) | +| `cmf.resource_post_fetch` | After resource fetch (CMF) | + +### 8.3 Custom Hooks + +Hosts register their own hook names. Any string works: + +```go +mgr.InvokeByName("inbound.pre_validation", cpex.PayloadGeneric, payload, ext, nil) +mgr.InvokeByName("outbound.pre_exchange", cpex.PayloadGeneric, payload, ext, nil) +``` + +## 9. Plugin Configuration (YAML) + +Plugins are declared in YAML and loaded via `LoadConfig`. The YAML is parsed by the Rust core. + +```yaml +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [identity-checker] + pii: + plugins: [pii-guard] + +plugins: + - name: identity-checker + kind: builtin/identity + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: + - read_labels + - read_subject + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + + - name: header-injector + kind: builtin/cmf-header-injector + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 50 + on_error: ignore + capabilities: + - read_headers + - write_headers + +routes: + # Tool-specific route โ€” tags applied to all invocations of this tool + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + - tool: list_departments + plugins: + - audit-logger + + # Wildcard route โ€” applies to all tools not matched above + - tool: "*" + plugins: + - audit-logger +``` + +### 9.1 Plugin Modes + +| Mode | Behavior | +|---|---| +| `sequential` | Serial execution, can block (deny) AND modify payload | +| `transform` | Serial execution, can modify payload but cannot block | +| `audit` | Serial execution, read-only (no modify, no block) | +| `concurrent` | Parallel execution, can block but cannot modify | +| `fire_and_forget` | Background execution, non-blocking, runs after pipeline completes | +| `disabled` | Plugin loaded but not executed | + +### 9.2 Error Handling (`on_error`) + +| Value | Behavior | +|---|---| +| `fail` | Halt pipeline, propagate error to caller | +| `ignore` | Log error, continue pipeline | +| `disable` | Log error, disable plugin for remaining lifetime, continue | + +### 9.3 Plugin Capabilities + +The optional `capabilities` list controls which extension fields a plugin can read and write. The Rust executor passes write tokens only for declared capabilities; undeclared extension slots arrive as `None` in the plugin's `handle()` call. + +| Capability | Extensions access granted | +|---|---| +| `read_labels` | `SecurityExtension.labels` (read) | +| `read_subject` | `SecurityExtension.subject` (read) | +| `read_headers` | `HttpExtension.request_headers` (read) | +| `write_headers` | `HttpExtension.response_headers` (read + write token) | + +Capabilities declared in YAML are enforced at the Rust core level โ€” a plugin cannot write to extensions it did not declare. See ยง6.4 for the Rust-side write pattern. + +### 9.4 Routes + +Routes match invocations by tool name and apply additional plugin overrides or tag injection. Evaluated in order; first match wins. The `"*"` wildcard matches any tool not matched by an earlier route. + +```yaml +routes: + # Exact match โ€” injects meta tags for this tool's invocations + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Exact match โ€” no meta tags + - tool: list_departments + plugins: + - audit-logger + + # Wildcard โ€” catch-all for remaining tools + - tool: "*" + plugins: + - audit-logger +``` + +The `meta.tags` field under a route entry augments (or sets) the `MetaExtension.Tags` seen by plugins for that tool, enabling tag-based policy groups to trigger without requiring the Go caller to set tags on every invocation. + +## 10. Integration Pattern + +The canonical integration pattern for a Go host: + +```go +package main + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +/* +// macOS: add -framework CoreFoundation -framework Security +// Linux: -lm -ldl -lpthread are sufficient +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lmy_plugins_ffi -lm -ldl -lpthread +#include +int my_register_factories(void* mgr); +*/ +import "C" + +func main() { + // 1. Create manager + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + panic(err) + } + defer mgr.Shutdown() + + // 2. Register custom plugin factories + if err := mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.my_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil + }); err != nil { + panic(err) + } + + // 3. Load configuration + yaml, err := os.ReadFile("plugins.yaml") + if err != nil { + panic(err) + } + if err := mgr.LoadConfig(string(yaml)); err != nil { + panic(err) + } + + // 4. Initialize plugins + if err := mgr.Initialize(); err != nil { + panic(err) + } + + // 5. Invoke hooks in the request lifecycle + ext := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + Security: &cpex.SecurityExtension{ + Subject: &cpex.SubjectExtension{ + ID: "user-123", + Roles: []string{"analyst"}, + }, + }, + } + + result, ct, bg, err := mgr.InvokeByName( + "tool_pre_invoke", cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "user": "alice"}, + ext, nil, + ) + if err != nil { + panic(err) + } + + if result.IsDenied() { + fmt.Printf("Denied: %s [%s]\n", result.Violation.Reason, result.Violation.Code) + ct.Close() + bg.Close() + return + } + + // 6. Thread context into post-invoke + result2, ct2, bg2, err := mgr.InvokeByName( + "tool_post_invoke", cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "result": "..."}, + ext, ct, // pass context from pre-invoke + ) + if err != nil { + panic(err) + } + _ = result2 + bg.Close() + bg2.Close() + ct2.Close() +} +``` + +## 11. Typed Invoke Pattern + +For hosts using CMF messages or custom structs with strong typing: + +```go +// Define a custom payload type with msgpack tags +type InboundPreValidationPayload struct { + Path string `msgpack:"path"` + Audience string `msgpack:"audience"` +} + +// Invoke with type safety +result, ct, bg, err := cpex.Invoke[InboundPreValidationPayload]( + mgr, + "inbound.pre_validation", + cpex.PayloadGeneric, // serialized as generic msgpack + InboundPreValidationPayload{Path: "/api/v1/users", Audience: "my-api"}, + ext, + nil, +) +if err != nil { /* handle */ } + +// result.ModifiedPayload is *InboundPreValidationPayload (or nil if unmodified) +if result.ModifiedPayload != nil { + fmt.Println("Modified audience:", result.ModifiedPayload.Audience) +} +``` + +## 12. Zero-Cost Guard Pattern + +Check for registered plugins before constructing payloads: + +```go +if !mgr.HasHooksFor("inbound.pre_validation") { + // No plugins configured โ€” skip payload construction and FFI overhead + return handleRequestDirectly(req) +} + +// Only build payload and extensions if plugins are registered +payload := buildPreValidationPayload(req) +ext := buildExtensions(req) +result, ct, bg, err := mgr.InvokeByName("inbound.pre_validation", ...) +``` + +This pattern ensures zero cost when no plugins are configured for a hook point. + +## 13. Result Handling + +### 13.1 Allow/Deny + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if result.IsDenied() { + // Pipeline halted by a plugin + v := result.Violation + return denyResponse(v.Code, v.Reason, v.Description) +} +// Proceed with original or modified payload +``` + +### 13.2 Modified Payload + +```go +// Raw path โ€” manual deserialization +if len(result.ModifiedPayload) > 0 { + modified, err := cpex.DeserializePayload[MyPayload](result) + // use modified +} + +// Typed path โ€” automatic deserialization +typed, ct, bg, err := cpex.Invoke[MyPayload](mgr, hook, payloadType, payload, ext, nil) +if typed.ModifiedPayload != nil { + // use typed.ModifiedPayload directly +} +``` + +### 13.3 Modified Extensions + +```go +if len(result.ModifiedExtensions) > 0 { + ext, _ := result.DeserializeExtensions() + // Plugins may have enriched Security.Subject, added Labels, etc. +} +``` + +### 13.4 Background Tasks + +```go +// Option A: Wait for background tasks (e.g., at request boundary) +bgErrors, err := bg.Wait() +if err != nil { + // FFI-level failure โ€” e.g., ErrCpexInvalidHandle if the manager + // was shut down between Invoke and Wait. The handle is still + // safely consumed; no need to call Close after a Wait error. + log.Warn("bg.Wait failed:", err) +} +for _, e := range bgErrors { + log.Warn("background task error: plugin=%s code=%s msg=%s", + e.PluginName, e.Code, e.Message) +} + +// Option B: Fire and forget +bg.Close() +``` + +### 13.5 Metadata + +```go +if result.Metadata != nil { + // Aggregate metadata from all plugins in the chain + if decision, ok := result.Metadata["_decision_plugin"]; ok { + log.Info("decided by:", decision) + } +} +``` + +### 13.6 Pipeline Errors (ignore / disable) + +When a plugin fails and its `on_error` mode is `ignore` or `disable`, the pipeline continues and the failure is recorded in `result.Errors` rather than halting via `result.Violation`. This is the canonical surface for non-fatal plugin errors that callers may still want to act on. + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if err != nil { /* FFI-level error */ } +if result.IsDenied() { /* halted by a fail/deny plugin */ } + +// Pipeline ran to completion. Inspect any soft errors. +for _, e := range result.Errors { + if e.PluginName == "" { + // Framework-emitted โ€” e.g., the modified payload couldn't be + // re-serialized across the FFI boundary. The rest of the + // result is still valid; the plugin's modification was + // dropped. + metrics.Inc("cpex.ffi_serialize_error") + } else { + // Plugin-attributed โ€” the plugin failed but ran with + // on_error: ignore/disable, so we got here. Code is the + // plugin's machine-readable identifier. + log.Warn("plugin %s failed [%s]: %s", e.PluginName, e.Code, e.Message) + } +} +``` + +Note that `result.Errors` is *separate* from `result.Violation` โ€” a violation halts the pipeline (no further plugins run); errors recorded here mean the pipeline kept going. + +## 14. Error Handling + +CPEX Go classifies errors via typed sentinels. Use `errors.Is(err, ErrCpexX)` rather than string-matching `err.Error()` โ€” the message text is not part of the API. + +### 14.1 Sentinels + +```go +var ( + // ErrCpexInvalidHandle: the manager handle is null or the + // manager was shut down. Returned when calling methods on a + // shut-down manager, or when BackgroundTasks.Wait runs after + // Shutdown. + ErrCpexInvalidHandle = errors.New("cpex: invalid handle ...") + + // ErrCpexInvalidInput: caller-supplied input was malformed โ€” + // bad UTF-8 in hookName, payloadType out of range, oversized + // buffer, etc. Calling code bug. + ErrCpexInvalidInput = errors.New("cpex: invalid input") + + // ErrCpexParse: parse / deserialize failed (YAML config, + // MessagePack payload, MessagePack extensions). Often a wire + // format mismatch between Go and Rust struct definitions. + ErrCpexParse = errors.New("cpex: parse / deserialize failed") + + // ErrCpexPipeline: pipeline / lifecycle step failed โ€” + // load_config returned Err, initialize returned Err, or a + // plugin signalled failure during invoke (without timeout or + // panic). The plugin's structured error is in result.Errors + // when on_error is ignore/disable. + ErrCpexPipeline = errors.New("cpex: pipeline / lifecycle error") + + // ErrCpexSerialize: result serialization failed after the + // pipeline ran โ€” usually OOM on rmp_serde::to_vec_named, or an + // unserializable JSON value. Distinct from the per-modified- + // payload synthetic error in result.Errors (see ยง5.7). + ErrCpexSerialize = errors.New("cpex: result serialize failed") + + // ErrCpexTimeout: the FFI wall-clock timeout (60s) was + // exceeded. A plugin is likely CPU-bound or blocking the OS + // thread without yielding. Rust per-plugin timeouts only + // catch cooperative-async timeouts; this catches the rest. + ErrCpexTimeout = errors.New("cpex: wall-clock timeout") + + // ErrCpexPanic: a plugin panicked; caught by catch_unwind at + // the FFI boundary. Indicates a bug in plugin Rust code. + ErrCpexPanic = errors.New("cpex: plugin panicked") +) +``` + +### 14.2 Classification Pattern + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if err != nil { + switch { + case errors.Is(err, cpex.ErrCpexTimeout): + metrics.Inc("cpex.timeout") + return retryWithBackoff(req) + case errors.Is(err, cpex.ErrCpexPanic): + // Plugin bug โ€” log, alert, fail closed. + metrics.Inc("cpex.panic") + return denyOnPluginPanic() + case errors.Is(err, cpex.ErrCpexInvalidHandle): + // Manager has been shut down โ€” recreate or fail closed. + return errors.New("plugin runtime offline") + default: + // ErrCpexParse / Serialize / Pipeline / InvalidInput โ€” + // typically caller or config bugs. + log.Error("cpex invoke:", err) + return err + } +} +``` + +### 14.3 Two error channels + +CPEX Go reports failures through two distinct channels, and they have different semantics: + +| Channel | Triggers | Meaning | +|---|---|---| +| `error` return value | FFI-level failures (timeout, panic, parse, invalid handle) | The pipeline did not complete usefully โ€” `result` is `nil` | +| `result.Errors` | Plugin failures with `on_error: ignore` or `on_error: disable`; FFI-layer modified-payload serialize failures | The pipeline ran to completion โ€” `result` is valid; treat as soft errors | + +A pipeline can return `err == nil`, `result.IsDenied() == false`, AND non-empty `result.Errors`. That means: "everything ran, nothing halted, but here are the things that didn't work." Don't ignore `result.Errors` just because `err` was nil. + +## 15. Serialization + +All types use `msgpack` struct tags matching Rust field names for zero-copy serialization across the FFI boundary. The wire format is MessagePack with named fields (`rmp_serde::to_vec_named` on the Rust side). + +**Rules:** +- Go struct fields map 1:1 to Rust struct fields via `msgpack:"field_name"` tags. +- Optional fields use `omitempty` โ€” nil/zero values are not serialized. +- `ContentPart` uses custom `EncodeMsgpack`/`DecodeMsgpack` for tagged-union encoding. +- Byte slices (`[]byte`) are serialized as MessagePack binary, not arrays. + +## 16. Thread Safety + +- `PluginManager` is safe for concurrent use from multiple goroutines. The Go wrapper holds a `sync.RWMutex` so concurrent `Invoke*` calls take the read lock while `Shutdown` takes the write lock โ€” preventing a use-after-free if Shutdown lands between an in-flight invoke and its FFI return. +- The Rust core uses `ArcSwap` for the registry โ€” concurrent invokes read a stable snapshot; mutations clone-and-swap. This means an in-flight invoke sees the registry as it was when the invoke started, not as it is mid-call. +- `ContextTable` is NOT safe for concurrent use โ€” it represents per-request state that is threaded sequentially through hook invocations. +- `BackgroundTasks` is safe to call `Wait()` or `Close()` from any goroutine, but only once. + +## 17. Gaps and Unimplemented Features + +The following features exist in the Python CPEX implementation but are not yet exposed in the Go API. These are tracked for future implementation: + +| Feature | Python Location | Status in Go | +|---|---|---| +| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented โ€” no single-plugin invoke | +| `HookPayloadPolicy` (field-level write control) | `manager.py` / `hooks/policies.py` | Handled in Rust core via plugin capabilities, not configurable from Go | +| `TenantPluginManager` (per-tenant isolation) | `manager.py` | Not implemented โ€” single global manager only (multi-tenant hosts can use one manager per tenant since Pass 9's shared runtime caps total threads) | +| Plugin introspection | `hooks/registry.py` | Partial โ€” `HasHooksFor`, `PluginCount`, `PluginNames`, `IsInitialized` exposed; per-hook plugin lookup is not | +| Observability provider injection | `manager.py` | Not exposed โ€” observability configured in Rust | +| Plugin conditions (runtime skip) | `manager.py` | Handled in Rust core via YAML config (`MatchContext` evaluated against extensions) | +| `OnError.DISABLE` runtime status query | `manager.py` | Not exposed (errors from disabled plugins surface in `result.Errors` though) | +| `reset()` (reinitialize without restart) | `manager.py` | Not implemented โ€” shutdown and recreate | +| Programmatic capability gating | `extensions/tiers.py` | YAML-only โ€” capabilities declared per-plugin in config (ยง9.3); no runtime API to override or rebind capabilities per-invoke | +| gRPC/Unix/MCP external plugin transports | `framework/external/` | Not yet in Rust core | +| Plugin loader with search paths | `loader/` | Rust uses factory registration instead | +| PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet in Rust core | +| Isolated (subprocess) plugins | `framework/isolated/` | Not yet in Rust core | +| `retry_delay_ms` in result | `models.py` | Not exposed in FFI result | + +## 18. Build & Test + +The repo ships a Makefile with the canonical commands. The raw `cargo` / `go` invocations are still listed below for environments without `make`. + +### 18.1 Make targets (recommended) + +| Target | What it does | +|---|---| +| `make rust-build` / `rust-build-release` | Build Rust workspace (debug / release) | +| `make rust-test` | Full Rust workspace tests | +| `make rust-test-ffi` | Only the cpex-ffi crate tests | +| `make rust-lint-check` | Read-only `cargo fmt --check` + `cargo clippy -- -D warnings` | +| `make rust-lint` (or `rust-lint-fix`) | Mutating: `cargo fmt` + `clippy --fix` | +| `make go-build` | Build the Go cpex package (auto-rebuilds cdylib first) | +| `make go-test` / `go-test-race` | Go tests (with optional race detector) | +| `make go-lint-check` | Read-only `gofmt -l` + `go vet` + `golangci-lint run` | +| `make go-lint` (or `go-lint-fix`) | Mutating: `gofmt -w` + `vet` + `golangci-lint run --fix` | +| `make examples-build` | Build all 4 examples โ€” catches stale public-API usage | +| `make examples-run` | Build + run each example end-to-end | +| `make test-all` | `rust-test` + `go-test-race` (the canonical "everything") | +| `make ci` | `rust-lint-check` + `test-all` + `examples-build` (the CI gate) | + +`golangci-lint` is required for `go-lint*`; install with `brew install golangci-lint`. + +### 18.2 Raw commands + +```bash +# 1. Build the Rust FFI library +cargo build --release -p cpex-ffi + +# 2. Run Go tests (links against libcpex_ffi) +cd go/cpex && go test -count=1 -race ./... + +# 3. Run the demo (requires demo plugin library) +cd examples/go-demo/ffi && cargo build --release +cd examples/go-demo && go run . + +# 4. Run the CMF demo +cd examples/go-demo && go run ./cmd/cmf-demo +``` + +**Platform notes:** +- macOS: link with `-framework CoreFoundation -framework Security` +- Linux: link with `-lm -ldl -lpthread` +- The `#cgo LDFLAGS` directive in `ffi.go` points to `target/release/` + diff --git a/docs/specs/cpex-rust-spec.md b/docs/specs/cpex-rust-spec.md new file mode 100644 index 00000000..9599fb4e --- /dev/null +++ b/docs/specs/cpex-rust-spec.md @@ -0,0 +1,1529 @@ +# CPEX Rust โ€” Public API Specification + +**Status**: Draft +**Date**: May 2026 +**Source**: `crates/cpex-core` in `github.com/contextforge-org/contextforge-plugins-framework` + +CPEX Rust is the core plugin runtime โ€” pure Rust, no FFI/WASM/PyO3 dependencies. It serves two audiences: + +- **Embedders** โ€” Rust hosts that want an in-process plugin pipeline (configure โ†’ load โ†’ invoke). +- **Plugin authors** โ€” code that runs *inside* the runtime as native Rust plugins (define hooks, write `HookHandler` impls). + +The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer of cpex-core via cpex-ffi. Other language bindings layer the same way. This spec documents the Rust API directly, with a focus on plugin authoring (ยง6, ยง11). + +## 1. Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Rust Host โ”‚ +โ”‚ โ”‚ +โ”‚ PluginManager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ PluginManager::new(ManagerConfig) โ”‚ โ”‚ +โ”‚ โ”‚ PluginManager::from_config(path, factories)โ”‚ โ”‚ +โ”‚ โ”‚ register_handler::(plugin, config) โ”‚ โ”‚ +โ”‚ โ”‚ initialize().await โ”‚ โ”‚ +โ”‚ โ”‚ invoke::(payload, ext, ct).await โ”‚ โ”‚ +โ”‚ โ”‚ invoke_named::(name, payload, ext, ct) โ”‚ โ”‚ +โ”‚ โ”‚ has_hooks_for(name) / plugin_count() โ”‚ โ”‚ +โ”‚ โ”‚ shutdown().await โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Executor โ–ผ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ 5-Phase Pipeline โ”‚ โ”‚ +โ”‚ โ”‚ 1. Sequential โ€” block + modify โ”‚ โ”‚ +โ”‚ โ”‚ 2. Transform โ€” modify only โ”‚ โ”‚ +โ”‚ โ”‚ 3. Audit โ€” read-only, serial โ”‚ โ”‚ +โ”‚ โ”‚ 4. Concurrent โ€” block-only, parallel โ”‚ โ”‚ +โ”‚ โ”‚ 5. FireAndForget โ€” background tasks โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Plugins โ–ผ โ”‚ +โ”‚ impl Plugin + impl HookHandler โ”‚ +โ”‚ โ€ข Capability-gated extension reads/writes โ”‚ +โ”‚ โ€ข Async lifecycle, sync handle() โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Key design decisions:** + +- **Typed dispatch is the default.** The recommended API is `invoke::(payload, ...)`, where `H: HookTypeDef` carries the payload type at compile time. The compiler enforces payload/hook compatibility โ€” there's no `Box` in user code on the happy path. +- **Hook types are open** โ€” hosts define their own via the `HookTypeDef` trait or the `define_hook!` macro. cpex-core ships built-ins (`tool_pre_invoke`, CMF hooks) but does not require them. +- **Capabilities at config** โ€” extension visibility and write authority are declared in YAML (or programmatically on `PluginConfig`). The executor enforces them by handing out `WriteToken`s only for declared capabilities. +- **Async-by-default handler.** Both plugin lifecycle (`initialize`, `shutdown`) and the per-invocation `handle(...)` are `async`. Handlers that don't need to await anything compile to a trivially-ready future that LLVM inlines, so there is no cost over a plain function call. Handlers that do need to await just `.await` inside the body. See ยง6.2 for the cost breakdown and the guidance on when to put `.await` in `handle`. + +## 2. Crate Layout & Dependencies + +```toml +[dependencies] +cpex-core = { git = "https://github.com/contextforge-org/contextforge-plugins-framework", branch = "main" } +async-trait = "0.1" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +**Module overview** (everything is `pub` unless marked): + +| Module | Purpose | +|---|---| +| `cpex_core::plugin` | `Plugin` trait, `PluginConfig`, `PluginMode`, `OnError`, `PluginCondition`, `MatchContext` | +| `cpex_core::hooks` | `HookTypeDef`, `HookHandler`, `PluginPayload`, `PluginResult

`, `define_hook!` | +| `cpex_core::factory` | `PluginFactory`, `PluginInstance`, `PluginFactoryRegistry` | +| `cpex_core::manager` | `PluginManager`, `ManagerConfig` | +| `cpex_core::executor` | `PipelineResult`, `BackgroundTasks` | +| `cpex_core::registry` | `HookEntry`, `PluginRef`, `group_by_mode` (rarely used directly) | +| `cpex_core::config` | `CpexConfig`, `load_config`, `parse_config` | +| `cpex_core::extensions` | `Extensions`, `OwnedExtensions`, all extension types, `WriteToken`, `Guarded`, `MonotonicSet` | +| `cpex_core::error` | `PluginError`, `PluginViolation`, `PluginErrorRecord` | +| `cpex_core::cmf` | CMF `MessagePayload`, `Message`, `ContentPart` | +| `cpex_core::context` | `PluginContext`, `PluginContextTable` | + +There are no feature flags currently โ€” everything is built unconditionally. cpex-ffi (the C ABI surface) lives in a separate crate and is not part of this spec. + +## 3. Lifecycle + +``` +ManagerConfig::default() โ†’ PluginManager::new() + โ”‚ + โ–ผ +register_factory(kind, factory) โ† optional, for kind-driven loading + โ”‚ + โ–ผ +load_config(yaml) | register_handler::(...) โ† either YAML or programmatic + โ”‚ + โ–ผ +initialize().await โ† calls Plugin::initialize() on each + โ”‚ + โ–ผ +invoke::(payload, ext, ct).await โ† repeatable; can be concurrent (preferred typed path) + โ”‚ + โ–ผ +shutdown().await โ† calls Plugin::shutdown() on each +``` + +## 4. Quick Reference + +| Operation | Method | +|---|---| +| Create manager | `PluginManager::new(ManagerConfig::default())` | +| Register factory | `mgr.register_factory(kind, Box::new(MyFactory))` | +| Load YAML config | `mgr.load_config(cpex_config)` or `mgr.load_config_file(path)` | +| Build from config | `PluginManager::from_config(path, &factories)` | +| Programmatic register | `mgr.register_handler::(plugin, config)` | +| Multiple hook names | `mgr.register_handler_for_names::(plugin, config, &names)` | +| Initialize | `mgr.initialize().await` | +| Query lifecycle | `mgr.is_initialized()` | +| Check hooks exist | `mgr.has_hooks_for(name)` | +| Count plugins | `mgr.plugin_count()` | +| List plugins | `mgr.plugin_names()` | +| Get plugin | `mgr.get_plugin(name)` โ†’ `Option>` | +| **Invoke (typed, primary)** | **`mgr.invoke::(payload, ext, ct).await`** | +| Invoke (typed + runtime name) | `mgr.invoke_named::(name, payload, ext, ct).await` | +| Invoke (untyped fallback) | `mgr.invoke_by_name(name, payload, ext, ct).await` | +| Define hook type | `define_hook!{ ToolPreInvoke; "tool_pre_invoke" => Payload(P) -> Result(R); }` | +| Define payload | `impl_plugin_payload!(MyPayload)` | +| Handler | `impl HookHandler for MyPlugin { async fn handle(...) -> H::Result { ... } }` | +| Allow result | `PluginResult::allow()` | +| Deny result | `PluginResult::deny(violation)` | +| Modify payload | `PluginResult::modify_payload(p)` | +| Modify extensions | `PluginResult::modify_extensions(owned)` | +| Wait background | `bg.wait().await` | +| Shutdown | `mgr.shutdown().await` | +| Unregister | `mgr.unregister(name)` | + +## 5. Core Types + +### 5.1 PluginManager + +The top-level object. Owns the plugin registry, factory registry, hook adapter table, and executor. + +```rust +pub struct PluginManager { /* private โ€” uses ArcSwap */ } + +impl PluginManager { + // Construction + pub fn new(config: ManagerConfig) -> Self; + pub fn default() -> Self; // ManagerConfig::default() + pub fn from_config( + path: &Path, + factories: &PluginFactoryRegistry, + ) -> Result>; + + // Factory registration + pub fn register_factory( + &self, + kind: impl Into, + factory: Box, + ); + + // YAML loading + pub fn load_config_file(&self, path: &Path) -> Result<(), Box>; + pub fn load_config(&self, cpex: CpexConfig) -> Result<(), Box>; + + // Programmatic plugin registration (preferred for native plugins) + pub fn register_handler( + &self, + plugin: Arc

, + config: PluginConfig, + ) -> Result<(), Box> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static; + + pub fn register_handler_for_names( + &self, + plugin: Arc

, + config: PluginConfig, + names: &[&str], + ) -> Result<(), Box> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static; + + pub fn register_raw( + &self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), Box>; + + // Lifecycle + pub async fn initialize(&self) -> Result<(), Box>; + pub async fn shutdown(&self); + pub fn is_initialized(&self) -> bool; + + // Query + pub fn has_hooks_for(&self, name: &str) -> bool; + pub fn plugin_count(&self) -> usize; + pub fn plugin_names(&self) -> Vec; + pub fn get_plugin(&self, name: &str) -> Option>; + pub fn unregister(&self, name: &str) -> Option>; + + // Invocation โ€” see ยง5.2 for the three flavors and when to use each + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks); + + pub async fn invoke_named( + &self, + hook_name: &str, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks); + + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks); +} +``` + +**Notes:** + +- `register_handler` is the **programmatic** registration path โ€” you supply the `Arc

` directly. It does not require a `PluginFactory`. Use this for plugins compiled into the same binary as the host. +- `from_config` is the **config-driven** path โ€” it reads YAML, looks up each plugin's `kind` in the factory registry, and calls `factory.create(config)` to instantiate. Use this for plugins selected by config. +- Both paths can coexist: register infrastructure plugins programmatically, then `load_config` to add the YAML-driven ones. +- Invoke methods take `&self` and are `async` โ€” multiple concurrent invokes are supported. The internal registry uses `ArcSwap` for lock-free reads. + +### 5.2 Choosing an Invoke Method + +Three flavors exist. **Default to `invoke::`.** The other two are for specific scenarios. + +```rust +// Primary: typed payload, hook name from H::NAME +let (result, bg) = mgr.invoke::(payload, ext, ct).await; + +// CMF pattern: typed payload, runtime hook name +let (result, bg) = mgr.invoke_named::("cmf.tool_pre_invoke", payload, ext, ct).await; + +// Last resort: type-erased payload (FFI/bridge code that already holds Box) +let (result, bg) = mgr.invoke_by_name("tool_pre_invoke", boxed_payload, ext, ct).await; +``` + +| Method | Payload type | Hook name source | When to use | +|---|---|---|---| +| `invoke::` | `H::Payload` (compile-time checked) | `H::NAME` constant | **Default for Rust callers.** Compiler verifies payload type matches the hook. One hook โ†’ one type. | +| `invoke_named::` | `H::Payload` (compile-time checked) | `&str` arg | One hook *type* covers multiple hook *names*. Used by the CMF pattern: `CmfHook` carries `MessagePayload` and is registered under `cmf.tool_pre_invoke`, `cmf.llm_input`, etc. | +| `invoke_by_name` | `Box` (type-erased) | `&str` arg | Bridge / FFI code that has already type-erased the payload (e.g., cpex-ffi after MessagePack deserialization). Avoid in user code. | + +All three return `(PipelineResult, BackgroundTasks)` directly โ€” no `Result`. The pipeline itself can fail (a plugin denied, a plugin errored with `on_error: fail`), but those are surfaced through `PipelineResult.violation`, `PipelineResult.errors`, and the `continue_processing` flag โ€” see ยง13. + +If the hook name has no registered handlers, all three short-circuit to `PipelineResult::allowed_with(payload, extensions, ct)` and return immediately โ€” zero overhead beyond a registry lookup. + +### 5.3 ManagerConfig + +```rust +pub struct ManagerConfig { + pub default_timeout: Duration, + pub default_on_error: OnError, + pub max_route_cache_size: usize, + /* additional fields โ€” see manager.rs */ +} + +impl Default for ManagerConfig { /* sensible defaults */ } +``` + +`ManagerConfig::default()` is fine for most hosts. Override `max_route_cache_size` if you have an exceptionally large set of `routes:` in YAML; override `default_timeout` for tighter SLAs. + +### 5.4 PluginConfig + +The declarative shape that drives plugin loading and runtime behavior. One entry per `plugins:` item in the YAML. + +```rust +pub struct PluginConfig { + pub name: String, // unique identifier + pub kind: String, // factory key (e.g., "builtin/identity") + pub description: Option, + pub author: Option, + pub version: Option, + pub hooks: Vec, // hook names this plugin handles + pub mode: PluginMode, // sequential / transform / audit / concurrent / fire_and_forget / disabled + pub priority: i32, // lower = earlier within phase (default 100) + pub on_error: OnError, // fail / ignore / disable + pub capabilities: HashSet, // extension read/write gates + pub tags: Vec, + pub conditions: Vec, // legacy scope filtering (ignored when routing_enabled) + pub config: Option, // plugin-specific settings (opaque to framework) +} +``` + +`config: Option` is where plugin-specific knobs live. The framework hands the JSON value to the plugin's factory; the factory deserializes it into a typed config struct. + +### 5.5 PluginMode + +```rust +#[non_exhaustive] +pub enum PluginMode { + Sequential, // serial, can block + modify + Transform, // serial, can modify (cannot block) + Audit, // serial, read-only + Concurrent, // parallel, can block (cannot modify) + FireAndForget, // background, cannot block or modify + Disabled, // skipped +} + +impl PluginMode { + pub fn can_block(&self) -> bool; // Sequential | Concurrent + pub fn can_modify(&self) -> bool; // Sequential | Transform + pub fn is_awaited(&self) -> bool; // not FireAndForget or Disabled +} +``` + +Modes determine *both* the phase the plugin runs in *and* the authority it has. The executor enforces this: + +| Mode | Phase | Receives | Can Block? | Can Modify? | +|---|---|---|---|---| +| `Sequential` | 1 | owned (clone) | Yes | Yes | +| `Transform` | 2 | owned (clone) | No | Yes | +| `Audit` | 3 | `&Payload` | No | No | +| `Concurrent` | 4 | `&Payload` | Yes | No | +| `FireAndForget` | 5 | `&Payload` | No | No | +| `Disabled` | โ€” | not invoked | โ€” | โ€” | + +### 5.6 OnError + +```rust +#[non_exhaustive] +pub enum OnError { + Fail, // halt pipeline (default) + Ignore, // log + record in PipelineResult.errors, continue + Disable, // log + record + auto-disable for the rest of process lifetime +} +``` + +`Ignore` and `Disable` failures land in `PipelineResult.errors` (a `Vec`); `Fail` failures halt the pipeline and surface via `PipelineResult.continue_processing == false` plus a populated `violation`. + +### 5.7 PipelineResult + +```rust +pub struct PipelineResult { + pub continue_processing: bool, + pub violation: Option, + pub modified_payload: Option>, + pub modified_extensions: Option, + pub metadata: HashMap, + pub errors: Vec, + pub context_table: PluginContextTable, +} + +impl PipelineResult { + pub fn is_denied(&self) -> bool; + pub fn allow() -> Self; + pub fn with_errors(self, errors: Vec) -> Self; +} +``` + +The aggregate output of running all phases for one invoke: + +- `continue_processing` โ€” `false` if any sequential plugin denied. The host should halt downstream work. +- `violation` โ€” populated when a plugin denied; carries the structured reason. +- `modified_payload` โ€” present only if at least one Sequential or Transform plugin produced a modification. Type-erased here for the same reason `invoke_by_name` exists: the executor drops below the type-parameter level. Downcast via `as_any()`, or use the typed-result helper in ยง5.8. +- `modified_extensions` โ€” present only if at least one capability-holding plugin called `modify_extensions(...)`. +- `errors` โ€” soft errors from `Ignore`/`Disable` plugins. Read these to surface non-fatal failures to logs/dashboards. +- `metadata` โ€” free-form aggregation key-value across plugins. Useful for `_decision_plugin`-style markers. +- `context_table` โ€” per-plugin state to thread into the next invoke. + +### 5.8 PluginResult<P> + +The **per-handler** result type, distinct from the per-invoke `PipelineResult`. Each `HookHandler::handle(...)` returns one of these. + +```rust +pub struct PluginResult { + pub continue_processing: bool, + pub violation: Option, + pub modified_payload: Option

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

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

`s into the final `PipelineResult`. + +To read a typed modified payload back from a `PipelineResult`: + +```rust +if let Some(boxed) = result.modified_payload.as_ref() { + if let Some(typed) = boxed.as_any().downcast_ref::() { + // typed: &ToolInvokePayload + } +} +``` + +If you stayed on the typed `invoke::` path, the `H::Payload` you sent in is the type to downcast to โ€” no surprises. + +### 5.9 PluginError + +The framework's error type. All public functions return `Result>`. + +```rust +#[derive(Debug, Error)] +pub enum PluginError { + Execution { + plugin_name: String, + message: String, + source: Option>, + code: Option, + details: HashMap, + proto_error_code: Option, + }, + Timeout { plugin_name: String, timeout_ms: u64, proto_error_code: Option }, + Violation { plugin_name: String, violation: PluginViolation }, + Config { message: String }, + UnknownHook { hook_type: String }, +} + +impl PluginError { + pub fn boxed(self) -> Box; // sugar for Box::new(self) +} +``` + +**Why boxed:** the enum is ~184 bytes (large `details` HashMap, `source` trait object). `Result>` keeps the success path pointer-sized; the allocation only happens on the error path. This is the standard Rust pattern for rich error types and is enforced by `clippy::result_large_err`. + +Construction is ergonomic: + +```rust +return Err(PluginError::Config { + message: "missing policy_file".into(), +}.boxed()); + +// `?` works automatically โ€” From for Box is in std: +let cfg: MyConfig = serde_json::from_value(raw)?; // serde error โ†— Box +``` + +### 5.10 PluginViolation + +Structured denial. Returned by plugins that want to halt the pipeline with a reason. + +```rust +pub struct PluginViolation { + pub code: String, // machine-readable identifier + pub reason: String, // short human-readable explanation + pub description: Option, // longer detail + pub details: HashMap, // structured diagnostic data + pub plugin_name: Option, // set by framework after return + pub proto_error_code: Option, // wire-protocol error code +} + +impl PluginViolation { + pub fn new(code: impl Into, reason: impl Into) -> Self; + pub fn with_description(self, description: impl Into) -> Self; + pub fn with_details(self, details: HashMap) -> Self; + pub fn with_proto_error_code(self, code: i64) -> Self; +} +``` + +### 5.11 PluginErrorRecord + +`Clone`-able snapshot of a `PluginError`. Lives in `PipelineResult.errors`. `PluginError` itself can't be `Clone` (the `source: Box` is not cloneable) and errors crossing the FFI boundary need `Serialize`/`Deserialize`. + +```rust +#[derive(Clone, Serialize, Deserialize)] +pub struct PluginErrorRecord { + pub plugin_name: String, + pub message: String, + pub code: Option, + pub details: HashMap, + pub proto_error_code: Option, +} + +impl From<&PluginError> for PluginErrorRecord { /* ... */ } +impl From<&Box> for PluginErrorRecord { /* forwarder */ } +``` + +The `From<&Box>` forwarder exists so call sites that hold `e: Box` can write `(&e).into()` without a manual deref. + +## 6. Plugin Authoring + +This is the heart of the API for plugin authors. The full minimal plugin is: + +1. Define a payload type and `impl_plugin_payload!` it. +2. Define a hook type implementing `HookTypeDef`. +3. Implement `Plugin` for the plugin struct. +4. Implement `HookHandler` for each hook the plugin handles. +5. Register the plugin (via `register_handler` or a `PluginFactory`). + +ยง11 walks through this end-to-end. This section explains each piece. + +### 6.1 The `Plugin` Trait + +Every plugin implements `Plugin`. It carries the plugin's config and the lifecycle hooks. + +```rust +#[async_trait] +pub trait Plugin: Send + Sync { + /// The plugin's configuration. Read-only โ€” the framework holds + /// the authoritative copy in `PluginRef.trusted_config`. + fn config(&self) -> &PluginConfig; + + /// One-time initialization. Called before any invokes. + /// Use to open connections, load resources, validate config. + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + /// Graceful shutdown. Called once during teardown. + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} +``` + +Default implementations for `initialize`/`shutdown` are no-ops; override only if your plugin needs them. + +### 6.2 The `HookHandler` Trait + +Each hook the plugin handles requires a separate `HookHandler` impl. The type parameter `H` is the hook type (a marker struct implementing `HookTypeDef`). + +```rust +pub trait HookHandler: Plugin + Send + Sync { + fn handle( + &self, + payload: &H::Payload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> impl std::future::Future + Send; +} +``` + +The `fn ... -> impl Future` shape is **native AFIT** (Associated Fn In Trait, stable since Rust 1.75). Plugin authors write the impl with the more familiar `async fn` form โ€” it desugars to the same thing: + +```rust +impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &MyPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +impl HookHandler for AuthzPlugin { + async fn handle( + &self, + payload: &MyPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + match self.client.check(&payload.user).await { + Ok(true) => PluginResult::allow(), + _ => PluginResult::deny(/* ... */), + } + } +} +``` + +**Borrow semantics:** + +- `payload: &H::Payload` โ€” always a borrow. The executor's mode-aware adapter passes either a clone (Sequential/Transform) or a true reference (Audit/Concurrent/FireAndForget). The plugin sees `&` either way; if it needs to mutate, it `clone()`s and returns `PluginResult::modify_payload(modified)`. +- `extensions: &Extensions` โ€” capability-filtered view. Slots the plugin lacks read capabilities for appear as `None`. +- `ctx: &mut PluginContext` โ€” per-plugin state. Read/write the local state map; stage updates to global state via `ctx.set_global(...)`. + +**Async by design.** `handle` is `async fn`. Plugins that don't need to await anything still write `async fn handle(...)` and return synchronously โ€” the compiler emits a trivially-ready future and LLVM inlines it at the adapter site, so there is no observable runtime cost over a plain function call. Plugins that *do* need to await (fresh JWKS fetch, RPC to authz, dynamic policy lookup) just use `.await` inside the body. + +**Registration is the same for both.** A single `register_handler::` call accepts a plugin whose `handle` body is purely sync as well as one that genuinely awaits โ€” the trait doesn't distinguish. + +```rust +manager.register_handler::(plugin, config)?; +``` + +**Cost:** + +- Plugins with no `.await` in `handle` compile to a `Ready` future that the executor awaits; LLVM typically inlines this to a direct call. No heap allocation, no scheduler interaction. +- Plugins that actually await pay normal async cost (one boxed future at the type-erased `AnyHookHandler` boundary, plus whatever the awaited work costs). Native AFIT is what avoids per-call boxing at the typed layer โ€” `#[async_trait]` would have boxed every call. + +**When to put `.await` in `handle`:** prefer caching at init time and reading from cache on the hot path โ€” that is the most common source of latency regressions in plugins. Only put `.await` in `handle` when caching genuinely won't work (e.g., per-request decisions against authoritative state). + +### 6.3 The `PluginPayload` Trait + +The base trait for all hook payloads. Object-safe โ€” the framework dispatches via `Box` internally, but plugin code rarely sees that directly when using `invoke::`. + +```rust +pub trait PluginPayload: Send + Sync + 'static { + fn clone_boxed(&self) -> Box; + fn as_any(&self) -> &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; +} +``` + +Implement it via the macro: + +```rust +use cpex_core::impl_plugin_payload; + +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: serde_json::Value, +} +impl_plugin_payload!(ToolInvokePayload); +``` + +The macro expands to the three method impls โ€” saves boilerplate per type. Requirements: the type must be `Clone + Send + Sync + 'static`. No `Serialize` is required by `PluginPayload` itself, but payloads that cross the FFI boundary (and so are deserializable from MessagePack) typically derive `serde::Serialize + Deserialize` too. + +### 6.4 Defining a Hook Type + +A hook type is a zero-sized marker struct that implements `HookTypeDef`. It associates a name (for registry lookup) with a typed payload and result. + +```rust +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} +``` + +**Conventions:** + +- `type Result = PluginResult` โ€” the standard shape. Custom result types are possible (the trait doesn't require `PluginResult`) but the executor wires `H::Result: Into>` so anything you return must convert into one. +- `NAME` is the lookup key for `register_handler::(...)` and the `hooks: [tool_pre_invoke]` line in YAML. It's also what `invoke::` uses for dispatch โ€” so calling `invoke::` is exactly equivalent to `invoke_by_name(H::NAME, ...)` with the type advantages. +- One marker can be shared across multiple hook *names* if your plugin handles a family. See the CMF pattern in ยง6.6. + +#### `define_hook!` macro (sugar) + +For the common case, a macro generates the marker struct, the trait impl, and a `HookHandler` shorthand in one declaration: + +```rust +use cpex_core::define_hook; + +define_hook! { + /// Hook for tool_pre_invoke. + ToolPreInvoke; + "tool_pre_invoke" => Payload(ToolInvokePayload) -> Result(PluginResult); +} +``` + +Either form is fine โ€” manual when you want fine control over docs/derives, the macro for less typing. + +### 6.5 PluginResult Constructors + +The four canonical outcomes a plugin signals: + +| Constructor | What it signals | +|---|---| +| `PluginResult::allow()` | Pass. No changes. | +| `PluginResult::deny(violation)` | Halt the pipeline. Caller sees `result.is_denied() == true`. | +| `PluginResult::modify_payload(p)` | Pass. Replace the payload in flight (Sequential/Transform only). | +| `PluginResult::modify_extensions(owned)` | Pass. Apply extension changes (capability-gated). | +| `PluginResult::modify(p, owned)` | Pass. Both payload and extension changes. | + +Audit / Concurrent / FireAndForget plugins should only use `allow()` and `deny()` โ€” `modify_*` calls in those modes are dropped by the executor (the plugin lacks the authority). + +### 6.6 Multiple Hooks per Plugin + +A single plugin can implement `HookHandler` for several hook types. Each `impl HookHandler` block is independent โ€” they can share `&self` state but don't have to. + +```rust +impl HookHandler for IdentityResolver { + async fn handle(&self, p: &ToolInvokePayload, e: &Extensions, c: &mut PluginContext) + -> PluginResult + { /* ... */ } +} + +impl HookHandler for IdentityResolver { + async fn handle(&self, p: &ToolInvokePayload, e: &Extensions, c: &mut PluginContext) + -> PluginResult + { /* ... */ } +} +``` + +Register each separately: + +```rust +manager.register_handler::( + Arc::clone(&plugin), config_for("tool_pre_invoke"))?; +manager.register_handler::( + plugin, config_for("tool_post_invoke"))?; +``` + +For the **CMF pattern** โ€” one handler covers many CMF hook *names* (`cmf.tool_pre_invoke`, `cmf.llm_input`, `cmf.llm_output`, etc.) all carrying the same `MessagePayload` โ€” define a single `CmfHook` marker and register it under multiple names: + +```rust +manager.register_handler_for_names::( + plugin, + config, + &[ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + "cmf.llm_output", + ], +)?; +``` + +This is the case where `invoke_named::("cmf.tool_pre_invoke", ...)` matters โ€” the type pins the payload to `MessagePayload`, but the runtime hook name selects which set of plugins to fire. + +### 6.7 Capability-Gated Extension Writes + +Extensions visible to a plugin are filtered by its declared `capabilities`. The framework uses copy-on-write tokens for writes โ€” the plugin clones the extensions, gets a `WriteToken` for slots it has capabilities for, and returns the modified copy. + +```rust +use cpex_core::hooks::payload::Extensions; + +async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, +) -> PluginResult { + let mut owned = extensions.cow_copy(); + + // http_write_token is Some(...) iff the plugin declared `write_headers` + if let Some(ref token) = owned.http_write_token { + if let Some(http) = owned.http.as_mut() { + let h = http.write(token); + h.set_response_header("X-Tool-Name", &payload.message.role); + h.set_response_header("X-CPEX-Processed", "true"); + } + } + + PluginResult::modify_extensions(owned) +} +``` + +A plugin without the capability sees `owned.http_write_token == None` and silently can't write โ€” no runtime panic, no security violation. The token *is* the type-system enforcement of the YAML capability. + +**Common capabilities** (see `cpex_core::extensions` for the full list): + +| Capability | Grants | +|---|---| +| `read_subject` | `SecurityExtension.subject` (read) | +| `read_labels` | `SecurityExtension.labels` (read) | +| `read_headers` | `HttpExtension.request_headers` (read) | +| `write_headers` | `HttpExtension.response_headers` (read + write token) | +| `read_classification` | `SecurityExtension.classification` (read) | +| `write_labels` | `SecurityExtension.labels` (read + monotonic write โ€” append-only) | +| `read_data` / `write_data` | `SecurityExtension.data` | +| `read_objects` | `SecurityExtension.objects` | + +`MonotonicSet`-typed fields (like `labels`) only allow append, never removal โ€” the executor enforces this on the post-handle merge. + +### 6.8 Choosing on_error + +`on_error` is set per-plugin in YAML or `PluginConfig`. Choose based on what failure means for the request: + +- **`fail`** โ€” security/policy plugins. Can't enforce โ†’ halt the request. +- **`ignore`** โ€” observability plugins (audit, metrics). Failure is annoying but non-fatal. +- **`disable`** โ€” non-essential plugins with potential to fail repeatedly (e.g., a stale external dependency). The framework auto-disables the plugin after one failure to stop log spam. + +`Ignore` and `Disable` failures are recorded in `PipelineResult.errors` โ€” they are not silent. + +## 7. Factories & Registration + +Two registration paths: + +| Path | When to use | +|---|---| +| `register_handler::` | You construct the plugin in Rust (compiled into the host). Direct, no factory needed. | +| `from_config(path, &factories)` | Plugin set is determined by YAML at runtime. Each `kind` in YAML maps to a registered `PluginFactory`. | + +You can mix them: register infrastructure plugins programmatically, then `load_config` to add YAML-configured ones. + +### 7.1 The PluginFactory Trait + +```rust +pub trait PluginFactory: Send + Sync { + fn create(&self, config: &PluginConfig) -> Result>; +} +``` + +A factory takes a `PluginConfig` (one entry from `plugins:` in YAML) and produces a `PluginInstance`. It's responsible for: + +1. Constructing the plugin (`Arc::new(MyPlugin { ... })`). +2. Building one `TypedHandlerAdapter` per hook the plugin handles. +3. Returning the bundle as a `PluginInstance`. + +### 7.2 PluginInstance + +```rust +pub struct PluginInstance { + pub plugin: Arc, + pub handlers: Vec<(&'static str, Arc)>, +} +``` + +`handlers` is one entry per hook name. For a plugin that handles two hooks (`tool_pre_invoke`, `tool_post_invoke`), the factory returns a `PluginInstance` with two entries. + +### 7.3 PluginFactoryRegistry + +```rust +pub struct PluginFactoryRegistry { /* private */ } + +impl PluginFactoryRegistry { + pub fn new() -> Self; + pub fn register(&mut self, kind: impl Into, factory: Box); + pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory>; + pub fn has(&self, kind: &str) -> bool; + pub fn kinds(&self) -> Vec<&str>; +} +``` + +Populate before calling `PluginManager::from_config(path, &factories)`. The manager dispatches by `config.kind` โ€” if the kind isn't registered, it returns `PluginError::Config { message: "unknown kind: ..." }`. + +## 8. Extensions + +Extensions are typed sidecar data carried alongside the payload. They are **always** a separate parameter โ€” never inside the payload โ€” because they need per-plugin capability filtering and independent modification. + +```rust +pub struct Extensions { + pub meta: Option>, + pub security: Option>, + pub http: Option>, + pub delegation: Option>, + pub agent: Option>, + pub request: Option>, + pub mcp: Option>, + pub completion: Option>, + pub provenance: Option>, + pub llm: Option>, + pub framework: Option>, + pub custom: HashMap, +} +``` + +| Extension | Purpose | +|---|---| +| `Meta` | Entity identification for route resolution (`entity_type`, `entity_name`, `tags`) | +| `Security` | Identity, labels, classification, data policies, authmethod, agent identity | +| `Http` | Request/response headers | +| `Delegation` | Token delegation chain (per-hop subject, audience, scope) | +| `Agent` | Agent execution context (session, conversation, turn) | +| `Request` | Environment, request ID, trace/span IDs, timestamp | +| `MCP` | MCP entity metadata (tool/resource/prompt server IDs) | +| `Completion` | LLM stats (stop reason, tokens, model, latency) | +| `Provenance` | Origin and message threading | +| `LLM` | Model identity (provider, capabilities) | +| `Framework` | Agentic framework context (framework name, node/graph IDs) | +| `Custom` | Free-form key-value | + +Each extension is held behind `Arc` so cloning the `Extensions` container is cheap โ€” only the field mutated needs a deep clone. `OwnedExtensions` is the mutable form returned from `cow_copy()`. + +For capability-gated writes, see ยง6.7. + +## 9. CMF Payloads & Hooks + +**CMF (ContextForge Message Format)** is a typed multi-part message used by the agentic-pipeline hooks (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). The full spec is in [cmf-message-spec.md](./cmf-message-spec.md); the highlights: + +```rust +use cpex_core::cmf::{Message, MessagePayload, ContentPart, Role}; +use serde_json::json; + +let msg = MessagePayload { + message: Message { + schema_version: "1.0".into(), + role: Role::User, + content: vec![ + ContentPart::Text("Look up compensation".into()), + ContentPart::ToolCall(ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: json!({"employee_id": 42}), + ..Default::default() + }), + ], + channel: None, + }, +}; +``` + +`MessagePayload` already implements `PluginPayload` โ€” no `impl_plugin_payload!` needed. + +**Built-in CMF hooks** (registered when you wire CMF into the manager): + +| Hook | Purpose | +|---|---| +| `cmf.tool_pre_invoke` | Before tool execution | +| `cmf.tool_post_invoke` | After tool execution | +| `cmf.llm_input` | Before LLM call | +| `cmf.llm_output` | After LLM response | +| `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch` | Prompt fetch lifecycle | +| `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` | Resource fetch lifecycle | + +A single plugin registers a `CmfHook` marker against multiple names with `register_handler_for_names`, then dispatches via `invoke_named::(name, ...)`. See ยง6.6. + +## 10. YAML Configuration + +The full structure of the config file consumed by `load_config_file`: + +```yaml +plugin_settings: + routing_enabled: true # turn on route resolution (vs legacy conditions) + plugin_timeout: 30 # default timeout in seconds + +global: + policies: + all: # reserved โ€” fires on every invocation + plugins: [identity-resolver] + pii: # custom group โ€” fires when route has "pii" tag + plugins: [pii-guard] + +plugins: + - name: identity-resolver + kind: builtin/identity # must match a registered factory key + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: [read_subject] + config: # opaque to framework โ€” passed to factory + strict_mode: true + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: [read_labels, read_subject] + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] # adds tags to MetaExtension for matching tools + plugins: + - audit-logger # route-specific override + + - tool: list_departments + plugins: + - audit-logger + + - tool: "*" # wildcard โ€” catch-all + plugins: + - audit-logger +``` + +**Routes** are evaluated in order; first match wins. The wildcard `"*"` catches anything not matched by an earlier route. `meta.tags` augments the `MetaExtension.tags` for the matched tool, which can then trigger tag-based policy groups. + +**Policy groups** are named bundles of plugins. The `"all"` group is reserved and always fires. Other groups (e.g., `pii`) fire when a route's tags include the group name. + +Loading: + +```rust +let mut factories = PluginFactoryRegistry::new(); +factories.register("builtin/identity", Box::new(IdentityFactory)); +factories.register("builtin/pii", Box::new(PiiFactory)); +factories.register("builtin/audit", Box::new(AuditFactory)); + +let manager = PluginManager::from_config(Path::new("plugins.yaml"), &factories)?; +manager.initialize().await?; +``` + +## 11. Sample Plugin: Full Worked Example + +This walks through a complete native-Rust plugin from payload definition to invocation. Source for reference: [crates/cpex-core/examples/plugin_demo.rs](../../crates/cpex-core/examples/plugin_demo.rs). + +### 11.1 Define the Payload + +```rust +use cpex_core::impl_plugin_payload; + +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} +impl_plugin_payload!(ToolInvokePayload); +``` + +### 11.2 Define the Hook Types + +```rust +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} +``` + +### 11.3 Implement the Plugin + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::HookHandler; +use cpex_core::plugin::{Plugin, PluginConfig}; + +/// Plugin that requires a non-empty `user` field on every invocation. +struct IdentityResolver { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityResolver { + fn config(&self) -> &PluginConfig { &self.cfg } + + async fn initialize(&self) -> Result<(), Box> { + println!("[identity-resolver] initialized"); + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + println!("[identity-resolver] shutdown"); + Ok(()) + } +} +``` + +### 11.4 Implement the Hook Handlers + +```rust +impl HookHandler for IdentityResolver { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + PluginResult::allow() + } +} + +impl HookHandler for IdentityResolver { + async fn handle( + &self, + _payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} +``` + +### 11.5 Build a Factory + +```rust +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::registry::AnyHookHandler; + +struct IdentityFactory; + +impl PluginFactory for IdentityFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityResolver { cfg: config.clone() }); + + let mut handlers: Vec<(&'static str, Arc)> = Vec::new(); + for hook in &config.hooks { + match hook.as_str() { + "tool_pre_invoke" => handlers.push(( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), + )), + "tool_post_invoke" => handlers.push(( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), + )), + other => return Err(PluginError::Config { + message: format!("identity-resolver doesn't handle hook '{}'", other), + }.boxed()), + } + } + + Ok(PluginInstance { + plugin: plugin as Arc, + handlers, + }) + } +} +``` + +### 11.6 Register and Invoke (Programmatic) + +Use `register_handler::` for compile-time dispatch and `invoke::` for the typed call path. The compiler enforces that the payload you pass matches `H::Payload`. + +```rust +use cpex_core::manager::{PluginManager, ManagerConfig}; +use cpex_core::plugin::{PluginConfig, PluginMode, OnError}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let manager = PluginManager::new(ManagerConfig::default()); + + // Programmatic registration โ€” skips the factory entirely. + let cfg = PluginConfig { + name: "identity-resolver".into(), + kind: "builtin/identity".into(), + hooks: vec!["tool_pre_invoke".into()], + mode: PluginMode::Sequential, + on_error: OnError::Fail, + ..Default::default() + }; + let plugin = Arc::new(IdentityResolver { cfg: cfg.clone() }); + manager.register_handler::(plugin, cfg)?; + + manager.initialize().await?; + + // Typed invoke โ€” payload type must be ToolPreInvoke::Payload (= ToolInvokePayload). + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: r#"{"employee_id": 42}"#.into(), + }; + + let (result, _bg) = manager.invoke::( + payload, + Extensions::default(), + None, + ).await; + + if result.is_denied() { + let v = result.violation.unwrap(); + eprintln!("DENIED: {} [{}]", v.reason, v.code); + } else { + println!("ALLOWED"); + } + + // Soft errors (on_error: ignore/disable plugins) land here. + for record in &result.errors { + eprintln!( + "soft error from {}: {}", + record.plugin_name, record.message, + ); + } + + manager.shutdown().await; + Ok(()) +} +``` + +### 11.7 Threading Context Across Hooks + +For pre/post hook pairs, thread the returned `PluginContextTable` from the pre-hook into the post-hook so each plugin sees its own `local_state` from earlier: + +```rust +let (pre_result, _bg) = manager.invoke::( + payload.clone(), ext.clone(), None, +).await; + +// Tool runs here ... +let tool_output = run_tool(&payload).await?; + +// Post-hook: pass pre_result.context_table so plugins see their stashed local_state. +let (post_result, _bg) = manager.invoke::( + payload, ext, Some(pre_result.context_table), +).await; +``` + +The first invoke takes `None`; subsequent invokes within the same logical request thread `Some(prev.context_table)` through. + +### 11.8 Register and Invoke (Config-driven) + +For YAML-driven registration, register the factory and call `from_config`: + +```rust +use cpex_core::factory::PluginFactoryRegistry; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut factories = PluginFactoryRegistry::new(); + factories.register("builtin/identity", Box::new(IdentityFactory)); + + let manager = PluginManager::from_config( + Path::new("plugins.yaml"), + &factories, + )?; + manager.initialize().await?; + + /* same invoke:: as above */ + + manager.shutdown().await; + Ok(()) +} +``` + +## 12. PluginContext & State + +Every `HookHandler::handle` call receives a `&mut PluginContext`. It carries two state stores: + +```rust +pub struct PluginContext { + pub plugin_id: PluginId, + pub local_state: HashMap, // per-plugin, persists across hooks + pub global_state: HashMap, // shared across plugins, scoped to one invoke chain + /* helpers */ +} +``` + +| Store | Scope | Use case | +|---|---|---| +| `local_state` | Plugin-private, persists across multiple hooks within one request (`tool_pre_invoke` โ†’ `tool_post_invoke`) | Stash per-request data the plugin will need on the corresponding post-hook (e.g., a timer started in pre, stopped in post) | +| `global_state` | Shared across plugins within one invoke chain | Pass data from one plugin to another (e.g., identity-resolver populates `user_id`, downstream plugins read it) | + +Threading `local_state` across hooks is the entire reason `PluginContextTable` exists โ€” the embedder threads the returned context table from one invoke into the next, and the framework hydrates each plugin's `local_state` from the table. + +`global_state` is committed back to a canonical store after each plugin runs (in Sequential phase) so the next plugin sees the merged view. + +## 13. Error Handling + +The framework surfaces failures through three channels, each with distinct semantics: + +| Channel | Triggers | Where it shows up | +|---|---|---| +| `Result<_, Box>` from `register_*`, `load_config`, `initialize` | Lifecycle errors: parse error, factory error, initialization error | Caller's `Err(...)` | +| `PipelineResult.violation: Option` | A plugin called `PluginResult::deny(...)` | Set when `result.is_denied() == true`; `result.continue_processing == false` | +| `PipelineResult.errors: Vec` | Plugin returned `Err` with `on_error: ignore` or `on_error: disable`; plugin timeout in non-blocking phase; FFI-layer issues | Soft-error log; pipeline still completed | + +Note: invoke methods (`invoke`, `invoke_named`, `invoke_by_name`) do **not** return `Result`. They always return `(PipelineResult, BackgroundTasks)`. All in-pipeline failures land in the channels above. This is deliberate โ€” once you've reached invoke, "the framework couldn't run anything" isn't a possible state; either no plugins matched (and you get an `allow` result) or the pipeline ran and produced a result. + +```rust +let (result, _bg) = mgr.invoke::(payload, ext, ct).await; + +if !result.continue_processing { + let v = result.violation.unwrap(); + eprintln!("denied [{}]: {}", v.code, v.reason); + return; // halt downstream work +} + +// Soft errors โ€” pipeline ran, but some plugins failed non-fatally. +for record in &result.errors { + log::warn!( + "plugin {} failed: {} ({})", + record.plugin_name, record.message, + record.code.as_deref().unwrap_or("-"), + ); +} +``` + +## 14. Threading & Async + +- `PluginManager` is `Send + Sync`. Use `Arc` and call `invoke::(&self, ...)` from many tasks concurrently. The internal registry uses `ArcSwap` for lock-free reads; mutations (registration, config load) clone-and-swap. +- Plugins must be `Send + Sync` (enforced by the `Plugin` trait bound). All plugin state shared via `&self` must be safe for concurrent access. +- `HookHandler::handle` is `async fn`. Plugins that don't need to await compile to a ready future with no observable cost; plugins that need to await per-invocation just use `.await`. Prefer caching state in `Plugin::initialize` and reading from cache on the hot path โ€” `.await` in `handle` adds latency to every request. Never call `block_on` inside `handle`; the manager already runs you on a tokio task and nested blocking will panic. +- The framework runs Concurrent-phase handlers in a `tokio::task::JoinSet` โ€” true parallelism if your plugins are CPU-bound. +- When embedded via cpex-ffi, all managers in the process share **one** tokio runtime. Worker thread count is configurable; see [cpex-go-spec.md](./cpex-go-spec.md) ยง5.9 for the FFI-side knobs. Within pure Rust, you control the runtime yourself (`#[tokio::main]` or manual `Runtime::new()`). + +## 15. Testing Plugins + +Native Rust plugins are easy to unit-test โ€” instantiate the plugin, build a `PluginContext`, call `handle` directly without going through the manager. + +```rust +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::context::PluginContext; + use cpex_core::hooks::payload::Extensions; + use cpex_core::plugin::PluginId; + + #[tokio::test] + async fn rejects_empty_user() { + let plugin = IdentityResolver { + cfg: PluginConfig { name: "test".into(), ..Default::default() }, + }; + let payload = ToolInvokePayload { + tool_name: "test".into(), + user: "".into(), + arguments: "{}".into(), + }; + let mut ctx = PluginContext::new(PluginId::from(1)); + let result = HookHandler::::handle( + &plugin, &payload, &Extensions::default(), &mut ctx).await; + assert!(result.violation.is_some()); + assert_eq!(result.violation.unwrap().code, "no_identity"); + } +} +``` + +For integration tests through the full pipeline, build a `PluginManager`, register the plugin, and `invoke::`. The `cpex-core` test suite has examples in `crates/cpex-core/src/manager.rs` (test module). + +## 16. Build & Test + +The repo Makefile is the canonical interface: + +| Target | What it does | +|---|---| +| `make rust-build` / `rust-build-release` | Build workspace (debug / release) | +| `make rust-test` | Full workspace tests | +| `make rust-test-ffi` | Only the cpex-ffi crate tests (faster iteration) | +| `make rust-lint-check` | Read-only `cargo fmt --check` + `cargo clippy -- -D warnings` | +| `make rust-lint` (or `rust-lint-fix`) | Mutating: `cargo fmt` + `clippy --fix` | +| `make examples-build` | Build all examples โ€” catches stale public-API usage | +| `make examples-run` | Build + run each example end-to-end | +| `make ci` | Full CI gate (lint-check + test-all + examples-build) | + +Raw commands: + +```bash +# Build cpex-core +cargo build -p cpex-core + +# Run example +cargo run --example plugin_demo -p cpex-core +cargo run --example cmf_capabilities_demo -p cpex-core + +# Tests +cargo test --workspace +``` + +## 17. Dynamic Plugin Loading + +> **Status:** both transports are shipped. The C ABI path lives in `cpex-ffi` (used by the Go integration). The Rust `cdylib` path lives in `cpex-dynamic-plugin` โ€” the architecture below applies to both; ยง17.5โ€“17.8 cover the cdylib-specific surface that shipped. + +The framework is built so dynamic plugins (loaded at runtime from a `.so` / `.dylib` / `.dll`) work without changing the typed plugin-author API. The architecture deliberately separates two layers: + +``` +HookHandler โ† native AFIT, monomorphized inside the plugin's binary + โ†“ wrapped by TypedHandlerAdapter at registration time +AnyHookHandler โ† object-safe; #[async_trait] boxes the future + โ†“ vtable +Arc โ† THIS is what crosses module boundaries +``` + +The typed `HookHandler` is non-object-safe (because of `impl Future` return-position) โ€” you can't have `Box>` and you definitely can't put one across `dlopen`. That's intentional. The plugin compiles its own `TypedHandlerAdapter` and erases to `Arc` *inside its own binary* before handing anything to the host. The host only ever sees `dyn AnyHookHandler`, which has a stable vtable. + +### 17.1 Two transport strategies + +| Strategy | Status | What crosses the boundary | +|---|---|---| +| **C ABI via cpex-ffi** | Shipped (Go integration) | `extern "C"` functions, opaque manager handles, MessagePack-encoded payloads. Plugins never touch `HookHandler` directly โ€” they implement whatever the FFI shim exposes. See [cpex-go-spec.md](./cpex-go-spec.md). | +| **Rust `cdylib` via `dlopen`** | Shipped in `cpex-dynamic-plugin` | A `cdylib` exports a registration entry point that returns a `PluginRegistration` containing an `Arc` plus a `Vec<(hook_name, Arc)>`. Host loads via `libloading`, registers via `DynamicPluginFactory` (scheme: `lib`). See ยง17.5โ€“17.8. | + +### 17.2 Async stays end-to-end + +Both transports preserve full async behavior: + +``` +host: arc_handler.invoke(payload, ext, ctx).await + โ†“ (vtable call across the module boundary) +plugin: TypedHandlerAdapter::invoke + โ†“ (downcast to H::Payload + .await) +plugin: handle(...).await // plugin can await JWKS, RPC, anything +``` + +`#[async_trait]` boxes the typed future into `Pin>` at the `AnyHookHandler` boundary. That boxed future is what crosses the module line. The host awaits it on its own tokio runtime; the plugin's `.await` points are pause points inside that future. + +### 17.3 Constraints and gotchas + +Independent of which transport you pick: + +- **Shared runtime.** The plugin's future doesn't carry its own runtime โ€” it gets driven by whichever tokio runtime the host is awaiting on. In the cpex-ffi path that's the process-shared runtime; in a Rust-cdylib path it'd be whatever the host has running. Plugins must not spawn or own a runtime themselves. +- **No nested `block_on`.** A dynamic plugin must never `block_on` inside `handle` โ€” the future is already running on a tokio task and nested blocking will panic. Same rule as in-tree plugins, but easier to forget when the plugin lives in someone else's repo. +- **Panic isolation.** The host wraps every `AnyHookHandler::invoke` call in `catch_unwind`. cpex-ffi already does this at the C boundary; a Rust `cdylib` host would do the same at the registration shim. + +Specific to the Rust `cdylib` path (`cpex-dynamic-plugin`): + +- **Same-version-only Rust ABI.** Plugin and host must be compiled with the same compiler version *and* same dependency versions. Different versions = UB. The shipped mitigation is a runtime `ABI_VERSION: u32` handshake at the entry point: the host passes its version, the plugin compares against its own compiled-against constant, mismatches return `EntryPointResult::AbiMismatch` before any unsafe access. Possible future extensions: + - A per-version plugin build container that ships with each CPEX release, so plugin authors get a guaranteed-matching toolchain via `cargo cpex plugin build` without thinking about versions. + - The `abi_stable` crate, which gives structurally-validated stable vtables at the cost of `R*`-wrapped types everywhere on the boundary and per-call wrapper overhead. Reserved for a future where third-party plugin authors distribute binaries independently of host releases. +- **Allocator boundaries.** A `Box`/`Arc` allocated by the plugin must be dropped by the same allocator. Both sides use `std::alloc::System` by default; plugin authors must not override `#[global_allocator]`. +- **Symbol visibility.** Entry points are emitted by the `cpex_dynamic_plugin!` / `cpex_dynamic_plugins!` macros as `#[no_mangle] pub unsafe extern "C"` so `dlsym` can find them. Authors never write the unsafe FFI by hand. +- **Library lifetime.** `Arc` vtables point into the cdylib's text section; unloading the library while any Arc is live would SIGSEGV. The host `Box::leak`s the `libloading::Library` handle so loaded plugins stay mapped for the rest of the process. Hot reload would require refcounting the library alongside all derived `Arc`s โ€” explicitly out of scope. +- **Panic across `extern "C"`.** Unwinding a panic across `extern "C"` is UB. The macros wrap the user's `create` closure in `catch_unwind` and return `EntryPointResult::Panic` on catch. Handler invocations rely on the same panic-isolation pattern as in-tree plugins. + +### 17.4 Why this works without changing the typed API + +The handler-collapse work in ยง6.2 (single async `HookHandler` trait) is orthogonal to dynamic loading. AFIT lives at the typed layer (inside the plugin's own binary); the module boundary lives at the type-erased layer. They don't collide. Plugin authors writing native, FFI, or cdylib plugins all write the same `async fn handle(...)` against the same `HookHandler` trait โ€” only the registration shim changes between transports. + +### 17.5 URL-shaped `kind` and factory scheme dispatch + +Operators reference dynamic plugins via a URL-shaped `kind:` string: + +``` +:[?entry=][#handler] +``` + +Components: + +- **`scheme`** โ€” selects which factory loads the plugin. Default for `cpex-dynamic-plugin` is `lib`; other future schemes (e.g., `wasm:`) can register independently. +- **`path`** โ€” filesystem path to the cdylib (absolute or relative; Windows drive letters supported via "first colon only" splitting). +- **`?entry=`** โ€” optional. Names a specific entry point inside a multi-plugin cdylib (ยง17.6). Validated as a C identifier (`[a-zA-Z_][a-zA-Z0-9_]*`) before any symbol lookup. +- **`#handler`** โ€” optional. Filters a multi-handler registration down to a single named handler. + +Worked examples: + +```yaml +plugins: + - name: my-plugin + kind: "lib:/opt/plugins/foo.so" # single plugin + + - name: identity + kind: "lib:/opt/plugins/auth.so#identity.resolve" # multi-handler, pick one + + - name: rate-limit + kind: "lib:/opt/plugins/multi.so?entry=rate_limiter" # multi-plugin + + - name: audit-post + kind: "lib:/opt/plugins/multi.so?entry=audit#cmf.tool_post_invoke" +``` + +The factory dispatch lives in `cpex_core::factory::PluginFactoryRegistry`: exact-`kind` matches win first, then `split_once(':')` falls back to a scheme registry. Hosts wire a dynamic-plugin factory via: + +```rust +mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); +``` + +Loader concerns (path, entry, handler filter) live in the kind URL by design โ€” the plugin's `config:` block stays purely the plugin's own settings. + +### 17.6 Single-plugin vs multi-plugin cdylibs + +Two plugin-author macros, chosen per cdylib based on what you're shipping: + +| Macro | Symbol(s) | Manifest | When | +|---|---|---|---| +| `cpex_dynamic_plugin!` | `cpex_plugin_create` | None | One plugin per cdylib. `?entry=` not used. | +| `cpex_dynamic_plugins!` | `cpex_plugin_create_` per entry | `cpex_plugin_list` discovery symbol | Several distinct plugins packaged in one cdylib. Operator selects via `?entry=`. | + +Both macros generate the unsafe FFI glue: ABI-version handshake, config deserialization, `catch_unwind`, ownership transfer of the `PluginRegistration` via `Box::into_raw` / `Box::from_raw` (system allocator both sides). + +A `PluginRegistration` carries: + +```rust +pub struct PluginRegistration { + pub abi_version: u32, + pub name: String, // plugin author-set; for diagnostics + pub version: String, + pub plugin: Arc, // primary plugin handle + pub handlers: Vec<(String, Arc)>, // hook_name โ†’ handler +} +``` + +Multi-handler is orthogonal to multi-plugin: each entry of a multi-plugin cdylib can itself register multiple handlers, addressable via the `#handler` URL fragment. + +### 17.7 Discovery: the optional `cpex_plugin_list` symbol + +Multi-plugin cdylibs export an optional discovery symbol so the host can validate `?entry=` up-front and produce friendly diagnostics: + +```rust +pub const LIST_SYMBOL: &[u8] = b"cpex_plugin_list"; +pub type ListFn = unsafe extern "C" fn() -> *const PluginManifest; + +pub struct PluginManifest { + pub abi_version: u32, + pub entries: &'static [PluginManifestEntry], +} + +pub struct PluginManifestEntry { + pub entry: &'static str, // the ?entry= value; matches cpex_plugin_create_ + pub name: &'static str, // human-readable display name + pub version: &'static str, + pub description: &'static str, +} +``` + +All manifest data is `&'static` โ€” baked into the cdylib's read-only memory at compile time. Nothing for the host to free, nothing for the plugin to reallocate. The macro emits the manifest as a `static` const. + +When the host sees `?entry=foo` and the manifest is present, it validates `foo` against `entries[].entry`. Unknown entries get `"no entry 'foo'. Available: [bar, baz]"`. When the manifest is absent (legacy single-plugin layout or operator opted out), the host falls through to plain `dlsym` and surfaces the raw "symbol not found" error. + +For hard-breaking changes to the manifest layout itself, the convention is to bump the symbol name (e.g., `cpex_plugin_list_v2`) rather than relying on `abi_version` alone โ€” same idea as `dlsym` symbol versioning. + +### 17.8 Feature gating: plugin vs host roles + +`cpex-dynamic-plugin` serves two audiences from one crate: + +- **Plugin authors** depend with default features. They get `abi`, `plugin` (the macros), but NOT `libloading`. Plugin-side builds stay light. +- **Hosts** depend with `--features host`. That pulls `libloading` and exposes `DynamicPluginFactory`. + +Keeping both roles in one crate eliminates the two-crate version-skew class of bugs (plugin and host always see the same ABI types because they're in the same package). + +## 18. Gaps and Unimplemented Features + +| Feature | Python Location | Status in Rust | +|---|---|---| +| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented โ€” no single-plugin invoke | +| `HookPayloadPolicy` (field-level write control) | `hooks/policies.py` | Not implemented โ€” capabilities are slot-level, not field-level | +| Programmatic capability rebinding per-invoke | `extensions/tiers.py` | Not implemented โ€” capabilities are config-level only | +| `TenantPluginManager` (multi-tenant in one manager) | `manager.py` | Not implemented โ€” one manager per tenant (shared runtime caps total threads when via FFI) | +| Observability provider injection | `manager.py` | Not implemented โ€” observability via `tracing` crate | +| `reset()` (reinitialize without restart) | `manager.py` | Not implemented โ€” shutdown and recreate | +| External plugin transports (gRPC/Unix/MCP) | `framework/external/` | Not yet implemented | +| Isolated (subprocess) plugins | `framework/isolated/` | Not yet implemented | +| PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet implemented | +| WASM plugin loader | `cpex-hosts::wasm` (planned) | Not yet implemented | +| Native (`dlopen`) plugin loader | `cpex-hosts::native` (planned) | Shipped in `cpex-dynamic-plugin` (see ยง17.5โ€“17.8) | +| `retry_delay_ms` in `PipelineResult` | `models.py` | Not implemented | + +The `cpex_core::plugin::Plugin` trait doc-comment mentions `cpex-hosts::{wasm,python,native}` host crates that would bridge to non-Rust plugin runtimes. None exist yet โ€” this is a design intent placeholder, not shipped functionality. diff --git a/examples/go-demo/.gitignore b/examples/go-demo/.gitignore new file mode 100644 index 00000000..8123b755 --- /dev/null +++ b/examples/go-demo/.gitignore @@ -0,0 +1,3 @@ +# Built demo binaries +cpex-demo +cmf-demo diff --git a/examples/go-demo/README.md b/examples/go-demo/README.md new file mode 100644 index 00000000..cfc4016b --- /dev/null +++ b/examples/go-demo/README.md @@ -0,0 +1,349 @@ +# CPEX Go Demo + +Two runnable examples showing the full CPEX plugin pipeline from Go, with plugins written in Rust and loaded via YAML configuration. + +## Prerequisites + +- **Go 1.21+** +- **Rust toolchain** (stable, 1.75+) + +## Build + +```bash +# 1. Build the demo FFI library (includes core + demo plugins) +cd examples/go-demo/ffi +cargo build --release + +# 2. Build the Go demos +cd examples/go-demo +go build -o cpex-demo . +go build -o cmf-demo ./cmd/cmf-demo/ +``` + +## Demo 1: Generic Payload (`cpex-demo`) + +Uses `PayloadGeneric` (untyped `map[string]any`) with three plugins: + +| Plugin | Kind | Mode | What it does | +|--------|------|------|-------------| +| identity-checker | `builtin/identity` | sequential | Validates `user` field present | +| pii-guard | `builtin/pii` | sequential | Blocks PII-tagged tools without clearance | +| audit-logger | `builtin/audit` | fire_and_forget | Logs tool invocations | + +### Run + +```bash +cd examples/go-demo +./cpex-demo +``` + +### Expected output + +``` +=== CPEX Go Demo === + +Plugins loaded: 3 +Hooks: tool_pre_invoke=true tool_post_invoke=true + +=== Scenario 1: get_compensation (no PII clearance) === + Result: DENIED โ€” PII clearance required for this operation [pii_access_denied] + +=== Scenario 2: get_compensation (with PII clearance) === + Result: ALLOWED + +=== Scenario 3: list_departments (non-PII tool) === + Result: ALLOWED + +=== Scenario 4: list_departments (no user identity) === + Result: DENIED โ€” User identity is required [no_identity] +``` + +### Config + +See [`plugins.yaml`](plugins.yaml) for the full configuration including routing rules and policy groups. + +## Demo 2: CMF Payload (`cmf-demo`) + +Uses `PayloadCMFMessage` (typed CMF messages) with rich extensions and two plugins: + +| Plugin | Kind | Mode | What it does | +|--------|------|------|-------------| +| tool-policy | `builtin/cmf-tool-policy` | sequential | Checks tool permissions against security labels | +| header-injector | `builtin/cmf-header-injector` | sequential | Injects response headers via capability-gated write | + +### Run + +```bash +cd examples/go-demo +./cmf-demo +``` + +### Expected output + +``` +=== CPEX CMF Demo === + +Plugins loaded: 2 + +=== Scenario 1: get_compensation tool call (no PII label) === + Result: DENIED โ€” Tool 'get_compensation' is PII-tagged but security context lacks PII label + +=== Scenario 2: get_compensation tool call (with PII label) === + Result: ALLOWED + Modified response headers: + X-Tool-Name: get_compensation + X-Tool-Status: success + X-CPEX-Processed: true + +=== Scenario 3: tool result post-invoke (header injection) === + Result: ALLOWED + Modified response headers: + X-Tool-Name: get_compensation + ... +``` + +### Config + +See [`cmf_plugins.yaml`](cmf_plugins.yaml) for capabilities and routing. + +## Architecture + +``` +Go (main.go) + โ”‚ + โ”‚ cpex.NewPluginManagerDefault() + โ”‚ cpex.RegisterFactories(callback) โ† one raw C call + โ”‚ cpex.LoadConfig(yaml) + โ”‚ cpex.Initialize() + โ”‚ cpex.InvokeByName(hook, payload, extensions, ...) + โ”‚ + โ–ผ +Go SDK (go/cpex/) + โ”‚ MessagePack serialize payload + extensions + โ”‚ + โ–ผ +cgo FFI (libcpex_demo_ffi.a) + โ”‚ cpex_invoke() โ†’ Rust executor + โ”‚ + โ–ผ +Rust Plugins (examples/go-demo/ffi/src/) + โ”‚ Plugin::handle() โ†’ PluginResult + โ”‚ + โ–ผ +cgo FFI + โ”‚ MessagePack serialize result + modified extensions + โ”‚ + โ–ผ +Go SDK + โ”‚ PipelineResult { IsDenied(), Violation, ModifiedExtensions } + โ”‚ + โ–ผ +Go (main.go) +``` + +## Demo Crate Structure + +``` +examples/go-demo/ + main.go โ€” generic payload demo + plugins.yaml โ€” config for generic demo + cmf_plugins.yaml โ€” config for CMF demo + go.mod โ€” Go module (depends on go/cpex) + cmd/ + cmf-demo/ + main.go โ€” CMF payload demo + ffi/ + Cargo.toml โ€” Rust crate: cpex-demo-ffi + src/ + lib.rs โ€” C FFI: cpex_demo_register_factories() + demo_plugins.rs โ€” 3 generic plugins (identity, PII, audit) + cmf_plugins.rs โ€” 2 CMF plugins (tool-policy, header-injector) +``` + +The `cpex-demo-ffi` crate builds a staticlib that includes both the core `cpex-ffi` symbols and the demo plugin factories. Go links only this one library. + +## How Factory Registration Works + +The Go SDK's `PluginManager` wraps the Rust manager. Plugin factories are Rust code, so registration happens through a callback: + +```go +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + // handle is the raw Rust manager pointer + // Call your crate's C registration function + C.cpex_demo_register_factories(handle) + return nil +}) +``` + +This keeps the Go SDK generic โ€” it doesn't know about specific factories. Each Rust crate exports its own `register_*_factories()` function. + +--- + +# Adding New Payload Types and Hooks + +This section covers how to extend the system with new payload types for Go-to-Rust plugin pipelines. + +## Overview + +The CPEX payload type registry maps a `uint8` discriminator to a concrete Rust type for efficient deserialization across the FFI boundary. Currently: + +| ID | Constant | Rust Type | Go Type | +|----|----------|-----------|---------| +| 0 | `PAYLOAD_GENERIC` | `GenericPayload` | `map[string]any` | +| 1 | `PAYLOAD_CMF_MESSAGE` | `MessagePayload` | `MessagePayload` | + +## Step-by-Step: Adding a New Payload Type + +### 1. Define the Rust payload type + +In your Rust crate (e.g., `cpex-core` or a separate crate): + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MyPayload { + pub field_a: String, + pub field_b: i64, +} +cpex_core::impl_plugin_payload!(MyPayload); +``` + +### 2. Register the payload type in the FFI crate + +In `crates/cpex-ffi/src/lib.rs`: + +```rust +// Add the constant +pub const PAYLOAD_MY_TYPE: u8 = 2; + +// Add a deserialize arm +fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result<...> { + match payload_type { + // ... existing arms ... + PAYLOAD_MY_TYPE => { + let v: MyPayload = rmp_serde::from_slice(bytes)?; + Ok(Box::new(v)) + } + _ => Err(...) + } +} + +// Add a serialize arm +fn serialize_payload(payload: &dyn PluginPayload) -> Option<(u8, Vec)> { + // ... existing checks ... + if let Some(mp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(mp).ok().map(|b| (PAYLOAD_MY_TYPE, b)); + } + // ... +} +``` + +### 3. Define the Go struct + +In `go/cpex/types.go` (or a new file): + +```go +const PayloadMyType uint8 = 2 + +type MyPayload struct { + FieldA string `msgpack:"field_a"` + FieldB int64 `msgpack:"field_b"` +} +``` + +### 4. Use it + +```go +result, ct, bg, err := mgr.InvokeByName( + "my_hook", + cpex.PayloadMyType, + MyPayload{FieldA: "hello", FieldB: 42}, + ext, + nil, +) + +// Deserialize modified payload from result +modified, err := cpex.DeserializePayload[MyPayload](result) +``` + +### Total: 5 touch points + +1. Rust struct + `impl_plugin_payload!` +2. FFI constant +3. FFI `deserialize_payload` match arm +4. FFI `serialize_payload` downcast +5. Go struct with msgpack tags + +## Step-by-Step: Adding a New Hook Type + +Hooks define what payload goes in and what comes out. For Go callers, hooks are identified by string name (e.g., `"tool_pre_invoke"`). + +### 1. Define the hook type in Rust + +```rust +pub struct MyHook; +impl HookTypeDef for MyHook { + type Payload = MyPayload; + type Result = PluginResult; + const NAME: &'static str = "my_hook"; +} +``` + +### 2. Write a plugin that handles it + +```rust +impl HookHandler for MyPlugin { + fn handle( + &self, + payload: &MyPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // ... your logic ... + PluginResult::allow() + } +} +``` + +### 3. Create a factory and register it + +```rust +struct MyPluginFactory; +impl PluginFactory for MyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(MyPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("my_hook", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} +``` + +### 4. Register in your FFI crate + +```rust +pub fn register_my_factories(manager: &mut PluginManager) { + manager.register_factory("my-plugin-kind", Box::new(MyPluginFactory)); +} +``` + +### 5. Add to YAML config + +```yaml +plugins: + - name: my-plugin + kind: my-plugin-kind + hooks: [my_hook] + mode: sequential + priority: 10 +``` + +### 6. Invoke from Go + +```go +result, ct, bg, err := mgr.InvokeByName("my_hook", cpex.PayloadMyType, payload, ext, nil) +``` + +The Go side doesn't need to know about the Rust hook type โ€” it just uses the string name and the payload type constant. diff --git a/examples/go-demo/cmf_plugins.yaml b/examples/go-demo/cmf_plugins.yaml new file mode 100644 index 00000000..53664a36 --- /dev/null +++ b/examples/go-demo/cmf_plugins.yaml @@ -0,0 +1,60 @@ +# Location: ./examples/go-demo/cmf_plugins.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CMF Demo โ€” plugin configuration +# +# Two CMF plugins demonstrating typed message processing: +# - Tool policy: checks tool permissions against security labels +# - Header injector: adds response headers after processing +# +# Uses cmf.tool_pre_invoke and cmf.tool_post_invoke hooks with +# MessagePayload (typed CMF messages, not generic maps). + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [tool-policy] + pii: + plugins: [tool-policy] + +plugins: + - name: tool-policy + kind: builtin/cmf-tool-policy + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + + - name: header-injector + kind: builtin/cmf-header-injector + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 50 + on_error: ignore + capabilities: + - read_headers + - write_headers + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - header-injector + + - tool: list_departments + plugins: + - header-injector + + - tool: "*" + plugins: + - header-injector diff --git a/examples/go-demo/ffi/Cargo.toml b/examples/go-demo/ffi/Cargo.toml new file mode 100644 index 00000000..1cd1e60c --- /dev/null +++ b/examples/go-demo/ffi/Cargo.toml @@ -0,0 +1,27 @@ +# Location: ./examples/go-demo/ffi/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Demo FFI โ€” demo plugins for Go example. +# +# Builds a staticlib that includes cpex-ffi symbols transitively. +# Go links only this library to get both the core FFI surface and +# the demo plugin factories. + +[package] +name = "cpex-demo-ffi" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +cpex-core = { path = "../../../crates/cpex-core" } +cpex-ffi = { path = "../../../crates/cpex-ffi" } +async-trait = "0.1" +serde_json = "1" +tracing = "0.1" diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs new file mode 100644 index 00000000..a033576f --- /dev/null +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -0,0 +1,274 @@ +// Location: ./examples/go-demo/ffi/src/cmf_plugins.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF demo plugins โ€” operate on MessagePayload (typed CMF messages). +// +// Two plugins demonstrating typed message inspection and +// capability-gated extension modification: +// +// - ToolPolicyPlugin: extracts tool calls from the CMF message, +// checks permissions against meta tags and security labels. +// PII-tagged tools require a "PII" label in the security +// extension; admin-tagged tools require an "admin" role. +// +// - HeaderInjectorPlugin: inspects tool calls/results and injects +// response headers (X-Tool-Name, X-Tool-Status, X-CPEX-Processed) +// using the capability-gated Guarded write pattern. +// Requires "write_headers" capability in the plugin config. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// --------------------------------------------------------------------------- +// CMF Hook Type +// --------------------------------------------------------------------------- + +/// Hook type for CMF message processing. The hook *name* varies +/// (cmf.tool_pre_invoke, cmf.tool_post_invoke, etc.) but the payload +/// is always MessagePayload. +pub struct CmfHook; + +impl HookTypeDef for CmfHook { + type Payload = MessagePayload; + type Result = PluginResult; + const NAME: &'static str = "cmf"; +} + +// --------------------------------------------------------------------------- +// Tool Policy Plugin +// --------------------------------------------------------------------------- + +/// Checks tool call permissions against security labels and meta tags. +/// +/// Policy rules: +/// - Tools tagged "pii" require security label "PII" in extensions +/// - Tools tagged "admin" require subject role "admin" +/// - All tool calls are logged with their arguments +struct ToolPolicyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for ToolPolicyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for ToolPolicyPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Extract tool calls from the CMF message + let tool_calls: Vec<_> = payload + .message + .content + .iter() + .filter_map(|cp| match cp { + ContentPart::ToolCall { content } => Some(content), + _ => None, + }) + .collect(); + + if tool_calls.is_empty() { + return PluginResult::allow(); + } + + // Check meta tags for PII requirement + let has_pii_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "pii")) + .unwrap_or(false); + + // Check security labels + let has_pii_label = extensions + .security + .as_ref() + .map(|s| s.labels.contains(&"PII".to_string())) + .unwrap_or(false); + + // If PII tagged but no PII label in security context โ€” deny + if has_pii_tag && !has_pii_label { + let tool_name = tool_calls + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + tracing::warn!( + "[tool-policy] DENIED: tool '{}' requires PII label but caller lacks it", + tool_name + ); + return PluginResult::deny(PluginViolation::new( + "pii_label_required", + format!( + "Tool '{}' is PII-tagged but security context lacks PII label", + tool_name + ), + )); + } + + // Check admin requirement + let has_admin_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "admin")) + .unwrap_or(false); + + if has_admin_tag { + let has_admin_role = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .map(|subj| subj.roles.iter().any(|r| r == "admin")) + .unwrap_or(false); + + if !has_admin_role { + return PluginResult::deny(PluginViolation::new( + "admin_required", + "This tool requires admin role", + )); + } + } + + for tc in &tool_calls { + tracing::info!( + "[tool-policy] OK: tool '{}' (call_id={}) authorized", + tc.name, + tc.tool_call_id, + ); + } + + PluginResult::allow() + } +} + +pub struct ToolPolicyFactory; + +impl PluginFactory for ToolPolicyFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(ToolPolicyPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Header Injector Plugin +// --------------------------------------------------------------------------- + +/// Adds response headers after tool execution. +/// +/// Inspects the CMF message (tool results) and adds: +/// - X-Tool-Name: name of the tool that ran +/// - X-Tool-Status: "success" or "error" +/// - X-CPEX-Processed: "true" +struct HeaderInjectorPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for HeaderInjectorPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for HeaderInjectorPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Look for tool results or tool calls + let tool_name = payload.message.content.iter().find_map(|cp| match cp { + ContentPart::ToolResult { content } => Some(content.tool_name.as_str()), + ContentPart::ToolCall { content } => Some(content.name.as_str()), + _ => None, + }); + + let is_error = payload.message.content.iter().any(|cp| { + matches!( + cp, + ContentPart::ToolResult { content } if content.is_error + ) + }); + + if let Some(name) = tool_name { + // COW copy โ€” clones mutable slots, propagates write tokens + let mut owned = extensions.cow_copy(); + + // Write to HTTP extension โ€” requires write token from capability + if let Some(ref token) = owned.http_write_token { + if let Some(http) = owned.http.as_mut() { + let h = http.write(token); + h.set_response_header("X-Tool-Name", name); + h.set_response_header( + "X-Tool-Status", + if is_error { "error" } else { "success" }, + ); + h.set_response_header("X-CPEX-Processed", "true"); + } + } + + return PluginResult::modify_extensions(owned); + } + + PluginResult::allow() + } +} + +pub struct HeaderInjectorFactory; + +impl PluginFactory for HeaderInjectorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(HeaderInjectorPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +/// Register CMF demo plugin factories on a manager. +pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); + manager.register_factory( + "builtin/cmf-header-injector", + Box::new(HeaderInjectorFactory), + ); +} diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs new file mode 100644 index 00000000..f27125c9 --- /dev/null +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -0,0 +1,275 @@ +// Location: ./examples/go-demo/ffi/src/demo_plugins.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Generic demo plugins for the Go example. +// +// Three plugins that operate on GenericPayload (serde_json::Value), +// demonstrating identity validation, PII policy enforcement, and +// audit logging through the CPEX plugin pipeline: +// +// - IdentityChecker: validates that a "user" field is present in +// the payload or a subject ID exists in security extensions +// - PiiGuard: blocks access to PII-tagged tools unless the payload +// contains a "pii_clearance" flag +// - AuditLogger: logs tool invocations with entity type, tool name, +// and user (fire-and-forget mode) + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use cpex_ffi::GenericPayload; + +// --------------------------------------------------------------------------- +// Generic Hook Type +// --------------------------------------------------------------------------- + +/// A hook type for FFI callers that send untyped map payloads. +/// The hook *name* varies at registration time (tool_pre_invoke, etc.) +/// but the payload type is always GenericPayload. +pub struct GenericHook; + +impl HookTypeDef for GenericHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "generic"; +} + +// --------------------------------------------------------------------------- +// Identity Checker +// --------------------------------------------------------------------------- + +struct IdentityChecker { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityChecker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for IdentityChecker { + async fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let user = payload.value.get("user").and_then(|v| v.as_str()); + + let subject_id = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.as_deref()); + + match user.or(subject_id) { + Some(u) if !u.is_empty() => { + tracing::info!("[identity-checker] OK: user '{}' identified", u); + PluginResult::allow() + } + _ => { + tracing::warn!("[identity-checker] DENIED: no user identity"); + PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )) + } + } + } +} + +pub struct IdentityCheckerFactory; + +impl PluginFactory for IdentityCheckerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityChecker { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// PII Guard +// --------------------------------------------------------------------------- + +struct PiiGuard { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for PiiGuard { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for PiiGuard { + async fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let has_pii_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "pii")) + .unwrap_or(false); + + let has_pii_label = extensions + .security + .as_ref() + .map(|s| s.labels.contains(&"PII".to_string())) + .unwrap_or(false); + + if !has_pii_tag && !has_pii_label { + return PluginResult::allow(); + } + + let has_clearance = payload + .value + .get("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if has_clearance { + tracing::info!("[pii-guard] OK: PII clearance verified"); + PluginResult::allow() + } else { + let tool_name = payload + .value + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::warn!( + "[pii-guard] DENIED: PII clearance required for '{}'", + tool_name + ); + PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required for this operation", + )) + } + } +} + +pub struct PiiGuardFactory; + +impl PluginFactory for PiiGuardFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(PiiGuard { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Audit Logger +// --------------------------------------------------------------------------- + +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLogger { + async fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let tool_name = payload + .value + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let user = payload + .value + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("anonymous"); + let entity_type = extensions + .meta + .as_ref() + .and_then(|m| m.entity_type.as_deref()) + .unwrap_or("unknown"); + + tracing::info!( + "[audit-logger] LOG: entity_type={} tool={} user={}", + entity_type, + tool_name, + user, + ); + PluginResult::allow() + } +} + +pub struct AuditLoggerFactory; + +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLogger { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +/// Register all demo plugin factories on a manager. +pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory)); + manager.register_factory("builtin/pii", Box::new(PiiGuardFactory)); + manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); +} diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs new file mode 100644 index 00000000..8f756f3a --- /dev/null +++ b/examples/go-demo/ffi/src/lib.rs @@ -0,0 +1,56 @@ +// Location: ./examples/go-demo/ffi/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Demo FFI โ€” re-exports cpex-ffi and adds demo plugin factories. +// +// This crate builds a staticlib that includes all cpex-ffi symbols +// transitively. Go links only this library โ€” no need to link +// libcpex_ffi separately. +// +// Exports one C function: `cpex_demo_register_factories()` which +// registers both generic and CMF demo plugin factories: +// +// Generic (GenericPayload): +// - `builtin/identity` โ€” identity checker +// - `builtin/pii` โ€” PII guard +// - `builtin/audit` โ€” audit logger +// +// CMF (MessagePayload): +// - `builtin/cmf-tool-policy` โ€” tool permission checking +// - `builtin/cmf-header-injector` โ€” response header injection + +mod cmf_plugins; +mod demo_plugins; + +// Force the linker to include all cpex-ffi symbols in our staticlib. +// Without this, the extern "C" functions from cpex-ffi would be +// stripped as "unused" since we don't call them from Rust. +extern crate cpex_ffi; + +use std::os::raw::c_int; + +/// Register demo plugin factories on the manager. +/// +/// Must be called after `cpex_manager_new_default()` and before +/// `cpex_load_config()`. Registers: +/// - `builtin/identity` โ€” identity checker +/// - `builtin/pii` โ€” PII guard +/// - `builtin/audit` โ€” audit logger +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new_default`. +#[no_mangle] +pub unsafe extern "C" fn cpex_demo_register_factories( + mgr: *mut cpex_ffi::CpexManagerInner, +) -> c_int { + let inner = match mgr.as_mut() { + Some(m) => m, + None => return -1, + }; + + demo_plugins::register_demo_factories(&mut inner.manager); + cmf_plugins::register_cmf_factories(&mut inner.manager); + 0 +} diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod new file mode 100644 index 00000000..4e5bff08 --- /dev/null +++ b/examples/go-demo/go.mod @@ -0,0 +1,12 @@ +module github.com/contextforge-org/contextforge-plugins-framework/examples/go-demo + +go 1.25.4 + +require github.com/contextforge-org/contextforge-plugins-framework/go/cpex v0.0.0 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) + +replace github.com/contextforge-org/contextforge-plugins-framework/go/cpex => ../../go/cpex diff --git a/examples/go-demo/go.sum b/examples/go-demo/go.sum new file mode 100644 index 00000000..fd15c1b8 --- /dev/null +++ b/examples/go-demo/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go-demo/main.go b/examples/go-demo/main.go new file mode 100644 index 00000000..33aecc16 --- /dev/null +++ b/examples/go-demo/main.go @@ -0,0 +1,242 @@ +// Location: ./examples/go-demo/main.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Go Demo โ€” generic payload example. +// +// Demonstrates the full CPEX plugin pipeline from Go using +// GenericPayload (untyped map payloads): +// +// 1. Create a PluginManager via the Go SDK +// 2. Register demo plugin factories (identity, PII, audit) +// 3. Load YAML config with routing rules and policy groups +// 4. Invoke hooks with MetaExtension for route resolution +// 5. Inspect results (allow/deny, violations) +// 6. Thread ContextTable between pre-invoke and post-invoke +// +// Build & run: +// +// cd examples/go-demo/ffi && cargo build --release +// cd examples/go-demo && go run main.go + +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_demo_ffi -lm -ldl -lpthread -framework CoreFoundation -framework Security +#include + +int cpex_demo_register_factories(void* mgr); +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +func main() { + fmt.Println("=== CPEX Go Demo ===") + fmt.Println() + + // --- Create manager --- + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + defer mgr.Shutdown() + + // --- Register demo factories via callback --- + err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + rc := C.cpex_demo_register_factories(handle) + if rc != 0 { + return fmt.Errorf("cpex_demo_register_factories returned %d", rc) + } + return nil + }) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + // --- Load YAML config --- + yaml, err := os.ReadFile("plugins.yaml") + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + if err := mgr.LoadConfig(string(yaml)); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + // --- Initialize --- + if err := mgr.Initialize(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Plugins loaded: %d\n", mgr.PluginCount()) + fmt.Printf("Hooks: tool_pre_invoke=%v tool_post_invoke=%v\n\n", + mgr.HasHooksFor("tool_pre_invoke"), + mgr.HasHooksFor("tool_post_invoke"), + ) + + // ----------------------------------------------------------------------- + // Scenario 1: PII tool WITHOUT clearance โ€” should be DENIED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 1: get_compensation (no PII clearance) ===") + fmt.Println() + + result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + "arguments": "employee_id=42", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 2: PII tool WITH clearance โ€” should be ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 2: get_compensation (with PII clearance) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + "arguments": "employee_id=42", + "pii_clearance": true, + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + + // Thread context table into post-invoke + fmt.Println(" --- post-invoke for get_compensation ---") + fmt.Println() + + result2, ct2, bg2, err := mgr.InvokeByName("tool_post_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + ct, // thread context table from pre-invoke + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result2) + bg2.Close() + ct2.Close() + + // ----------------------------------------------------------------------- + // Scenario 3: Non-PII tool โ€” should be ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 3: list_departments (non-PII tool) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "list_departments", + "user": "bob", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 4: No user identity โ€” should be DENIED by identity-checker + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 4: list_departments (no user identity) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "list_departments", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + fmt.Println("=== Demo complete ===") +} + +func printResult(result *cpex.PipelineResult) { + if !result.IsDenied() { + fmt.Printf(" Result: ALLOWED\n\n") + } else { + v := result.Violation + fmt.Printf(" Result: DENIED โ€” %s [%s]\n\n", v.Reason, v.Code) + } +} diff --git a/examples/go-demo/plugins.yaml b/examples/go-demo/plugins.yaml new file mode 100644 index 00000000..f7975cbb --- /dev/null +++ b/examples/go-demo/plugins.yaml @@ -0,0 +1,59 @@ +# Location: ./examples/go-demo/plugins.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Go Demo โ€” plugin configuration +# +# Three plugins with routing rules that demonstrate: +# - Identity validation on every invocation (global policy) +# - PII guarding on tagged tools (tag-based policy group) +# - Audit logging on all tools (fire-and-forget) + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [identity-checker] + pii: + plugins: [pii-guard] + +plugins: + - name: identity-checker + kind: builtin/identity + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + - tool: list_departments + plugins: + - audit-logger + + - tool: "*" + plugins: + - audit-logger diff --git a/go/cpex/README.md b/go/cpex/README.md new file mode 100644 index 00000000..220eef68 --- /dev/null +++ b/go/cpex/README.md @@ -0,0 +1,367 @@ +# CPEX Go SDK + +Go bindings for the CPEX plugin runtime. Wraps the Rust core via cgo โ€” all plugin execution happens in Rust, called from Go through MessagePack-serialized payloads and opaque handles. + +## Prerequisites + +- **Go 1.21+** +- **Rust toolchain** (stable, 1.75+) +- **Built Rust library**: the Go SDK links against `libcpex_ffi.a` + +```bash +# From the repository root +cargo build --release -p cpex-ffi +``` + +## Package Structure + +``` +go/cpex/ + ffi.go โ€” cgo declarations (C function signatures) + manager.go โ€” PluginManager, ContextTable, BackgroundTasks + types.go โ€” Extensions, PipelineResult, payload constants + cmf.go โ€” CMF Message, ContentPart, domain objects + manager_test.go โ€” tests (require built libcpex_ffi) +``` + +## Quick Start + +```go +import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" + +// 1. Create a manager +mgr, err := cpex.NewPluginManagerDefault() +defer mgr.Shutdown() + +// 2. Register plugin factories (Rust-side, via callback) +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + C.my_register_factories(handle) + return nil +}) + +// 3. Load YAML config +mgr.LoadConfig(yamlString) + +// 4. Initialize plugins +mgr.Initialize() + +// 5. Invoke a hook +result, ct, bg, err := mgr.InvokeByName( + "tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "user": "alice"}, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + }, + nil, // context table (nil for first call) +) +defer ct.Close() +defer bg.Close() + +if result.IsDenied() { + fmt.Printf("Denied: %s\n", result.Violation.Reason) +} +``` + +## Lifecycle + +``` +NewPluginManagerDefault() + โ†’ RegisterFactories(fn) // register Rust plugin factories + โ†’ LoadConfig(yaml) // parse YAML, instantiate plugins + โ†’ Initialize() // call plugin.initialize() on all + โ†’ InvokeByName(...) // invoke hooks, get results + โ†’ Shutdown() // call plugin.shutdown(), free resources +``` + +## Payload Types + +| Constant | Value | Description | +|----------------------|-------|----------------------------------| +| `PayloadGeneric` | 0 | Generic map payload (`map[string]any`) | +| `PayloadCMFMessage` | 1 | Typed CMF `MessagePayload` | + +Use `PayloadGeneric` for simple key-value payloads. Use `PayloadCMFMessage` when sending structured CMF messages with typed content parts (tool calls, resources, media, etc.). + +## CMF Content Types + +The `ContentPart` tagged union supports all 12 content types: + +| Type | Constructor | Content Field | +|------|-------------|---------------| +| `text` | `NewTextPart("hello")` | `Text` | +| `thinking` | `NewThinkingPart("...")` | `Text` | +| `tool_call` | `NewToolCallPart(tc)` | `ToolCallContent` | +| `tool_result` | `NewToolResultPart(tr)` | `ToolResultContent` | +| `resource` | `NewResourcePart(r)` | `ResourceContent` | +| `resource_ref` | `NewResourceRefPart(r)` | `ResourceRefContent` | +| `prompt_request` | `NewPromptRequestPart(pr)` | `PromptRequestContent` | +| `prompt_result` | `NewPromptResultPart(pr)` | `PromptResultContent` | +| `image` | `NewImagePart(img)` | `ImageContent` | +| `video` | `NewVideoPart(vid)` | `VideoContent` | +| `audio` | `NewAudioPart(aud)` | `AudioContent` | +| `document` | `NewDocumentPart(doc)` | `DocumentContent` | + +## Extensions + +Extensions are passed separately from the payload. Each extension type maps to a Rust extension in `crates/cpex-core/src/extensions/`: + +- `MetaExtension` โ€” entity identification for route resolution +- `SecurityExtension` โ€” labels, classification, subject identity +- `HttpExtension` โ€” request/response headers +- `DelegationExtension` โ€” token delegation chain +- `AgentExtension` โ€” agent session and conversation context +- `RequestExtension` โ€” environment, tracing, request ID +- `MCPExtension` โ€” MCP tool/resource/prompt metadata +- `CompletionExtension` โ€” LLM completion stats +- `ProvenanceExtension` โ€” message origin and threading +- `LLMExtension` โ€” model identity and capabilities +- `FrameworkExtension` โ€” agentic framework context + +## Context Threading + +Pass the `ContextTable` from one invocation to the next to preserve per-plugin state across hooks: + +```go +result1, ct1, bg1, _ := mgr.InvokeByName("tool_pre_invoke", ...) +bg1.Close() + +// Thread context table into post-invoke +result2, ct2, bg2, _ := mgr.InvokeByName("tool_post_invoke", ..., ct1) +bg2.Close() +ct2.Close() +``` + +## Writing Plugins (Rust) for Go Callers + +Plugins are written in Rust and compiled into a separate FFI crate that the Go program links. This keeps the core `cpex-ffi` library clean while allowing each project to bring its own plugins. + +### 1. Create a Rust FFI crate + +``` +my-project/ + plugins-ffi/ + Cargo.toml + src/ + lib.rs + my_plugin.rs +``` + +**`Cargo.toml`**: + +```toml +[package] +name = "my-plugins-ffi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +cpex-core = { path = "path/to/crates/cpex-core" } +cpex-ffi = { path = "path/to/crates/cpex-ffi" } +async-trait = "0.1" +tracing = "0.1" +``` + +### 2. Define your hook type and plugin + +**`src/my_plugin.rs`**: + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_ffi::GenericPayload; + +// Hook type โ€” the NAME can be any string; Go callers use this name +pub struct MyHook; +impl HookTypeDef for MyHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "my_hook"; +} + +// Plugin implementation +struct RateLimiter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RateLimiter { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for RateLimiter { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Your plugin logic here + PluginResult::allow() + } +} + +// Factory โ€” creates plugin instances from YAML config +pub struct RateLimiterFactory; +impl PluginFactory for RateLimiterFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(RateLimiter { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("my_hook", Arc::new( + TypedHandlerAdapter::::new(plugin), + )), + ], + }) + } +} + +pub fn register_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("my/rate-limiter", Box::new(RateLimiterFactory)); +} +``` + +### 3. Export the C registration function + +**`src/lib.rs`**: + +```rust +mod my_plugin; + +// Include cpex-ffi symbols in this staticlib +extern crate cpex_ffi; + +use std::os::raw::c_int; + +#[no_mangle] +pub unsafe extern "C" fn my_register_factories( + mgr: *mut cpex_ffi::CpexManagerInner, +) -> c_int { + let inner = match mgr.as_mut() { + Some(m) => m, + None => return -1, + }; + my_plugin::register_factories(&mut inner.manager); + 0 +} +``` + +### 4. Call from Go + +```go +/* +#cgo LDFLAGS: -L/path/to/target/release -lmy_plugins_ffi -lm -ldl -lpthread +int my_register_factories(void* mgr); +*/ +import "C" + +mgr, _ := cpex.NewPluginManagerDefault() + +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.my_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil +}) + +mgr.LoadConfig(yaml) // YAML references kind: "my/rate-limiter" +mgr.Initialize() + +result, ct, bg, _ := mgr.InvokeByName("my_hook", cpex.PayloadGeneric, payload, ext, nil) +``` + +### Key points + +- `extern crate cpex_ffi;` in your `lib.rs` ensures all core FFI symbols are included in your staticlib โ€” Go links only your library +- The `CpexManagerInner` type from `cpex_ffi` gives you access to the `manager` field for factory registration +- Your C function signature is `int my_register_factories(void* mgr)` โ€” Go passes the SDK's internal handle via the `RegisterFactories` callback +- The YAML `kind` field must match what you pass to `register_factory()` + +## Adding a New Payload Type + +The payload type registry maps a `uint8` discriminator to a Rust type for FFI deserialization. To add a new one: + +### Rust side (3 files) + +**1. Define the type** (in `cpex-core` or your own crate): + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MyPayload { + pub field_a: String, + pub field_b: i64, +} +cpex_core::impl_plugin_payload!(MyPayload); +``` + +**2. Register in FFI** (`crates/cpex-ffi/src/lib.rs`): + +```rust +pub const PAYLOAD_MY_TYPE: u8 = 2; + +// In deserialize_payload(): +PAYLOAD_MY_TYPE => { + let v: MyPayload = rmp_serde::from_slice(bytes)?; + Ok(Box::new(v)) +} + +// In serialize_payload(): +if let Some(mp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(mp).ok().map(|b| (PAYLOAD_MY_TYPE, b)); +} +``` + +### Go side (1 file) + +**3. Define the Go struct** (`go/cpex/types.go` or a new file): + +```go +const PayloadMyType uint8 = 2 + +type MyPayload struct { + FieldA string `msgpack:"field_a"` + FieldB int64 `msgpack:"field_b"` +} +``` + +### Use it + +```go +result, ct, bg, _ := mgr.InvokeByName("my_hook", cpex.PayloadMyType, payload, ext, nil) + +// Deserialize modified payload from result +modified, _ := cpex.DeserializePayload[MyPayload](result) +``` + +**Total: 5 touch points** โ€” Rust struct, FFI constant, deserialize arm, serialize arm, Go struct. No framework registration or config changes needed. + +## Tests + +```bash +# Build the Rust library first +cargo build --release -p cpex-ffi + +# Run Go tests +cd go/cpex && go test -v ./... +``` + +## See Also + +- [Go Demo Examples](../../examples/go-demo/README.md) โ€” runnable demos with YAML configs +- [Rust Core README](../../crates/README.md) โ€” core runtime documentation +- [Rust Examples](../../crates/cpex-core/examples/README.md) โ€” native Rust examples diff --git a/go/cpex/cmf.go b/go/cpex/cmf.go new file mode 100644 index 00000000..cfe82ead --- /dev/null +++ b/go/cpex/cmf.go @@ -0,0 +1,390 @@ +// Location: ./go/cpex/cmf.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF (ContextForge Message Format) types for Go. +// +// Mirrors the Rust types in crates/cpex-core/src/cmf/. The Message +// struct carries typed content parts (text, tool calls, resources, +// media, etc.) without extensions โ€” those are passed separately. +// +// ContentPart is a tagged union discriminated by the "content_type" +// field. Custom msgpack Encoder/Decoder methods produce the same +// wire format as Rust's #[serde(tag = "content_type")] enum. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// --------------------------------------------------------------------------- +// CMF Message Types +// --------------------------------------------------------------------------- + +// MessagePayload wraps a Message for FFI transport. +// Matches Rust's cpex_core::cmf::MessagePayload. +type MessagePayload struct { + Message Message `msgpack:"message"` +} + +// Message is the ContextForge Message Format (CMF) message. +// No extensions โ€” those are passed separately to the plugin pipeline. +type Message struct { + SchemaVersion string `msgpack:"schema_version"` + Role string `msgpack:"role"` + Content []ContentPart `msgpack:"content"` + Channel string `msgpack:"channel,omitempty"` +} + +// NewMessage creates a Message with the default schema version. +func NewMessage(role string, content ...ContentPart) Message { + return Message{ + SchemaVersion: "2.0", + Role: role, + Content: content, + } +} + +// --------------------------------------------------------------------------- +// Content Parts โ€” tagged union via content_type discriminator +// --------------------------------------------------------------------------- + +// ContentPart represents one element in a Message's content list. +// Uses custom msgpack marshaling to produce the tagged-union wire format: +// +// {"content_type": "text", "text": "hello"} +// {"content_type": "tool_call", "content": {...}} +// +// The ContentType field determines which content field is populated. +// Text and Thinking use the Text field directly; all other types use +// their respective content field. +type ContentPart struct { + ContentType string + + // Text/Thinking โ€” "text" field at top level + Text string + + // Structured content โ€” "content" field wrapping a domain object. + // Only one is set based on ContentType. + ToolCallContent *ToolCall + ToolResultContent *ToolResult + ResourceContent *Resource + ResourceRefContent *ResourceReference + PromptRequestContent *PromptRequest + PromptResultContent *PromptResult + ImageContent *ImageSource + VideoContent *VideoSource + AudioContent *AudioSource + DocumentContent *DocumentSource + + // rawMap captures the full original wire form for content_type + // values this Go SDK doesn't have a typed accessor for. Lets a + // newer Rust runtime emitting a future variant pass through + // older Go bindings without losing data on round-trip โ€” Encode + // emits rawMap verbatim when ContentType isn't a known case. + // Private because users with an unknown ContentType have no + // safe way to interpret it; they can only forward it. + rawMap map[string]any +} + +// EncodeMsgpack produces the tagged-union wire format. +func (cp ContentPart) EncodeMsgpack(enc *msgpack.Encoder) error { + // Helper: a body envelope wrapping a typed `content` value. + body := func(content any) map[string]any { + return map[string]any{ + wireKeyContentType: cp.ContentType, + wireKeyContent: content, + } + } + + switch cp.ContentType { + case ContentTypeText, ContentTypeThinking: + return enc.Encode(map[string]any{ + wireKeyContentType: cp.ContentType, + wireKeyText: cp.Text, + }) + case ContentTypeToolCall: + return enc.Encode(body(cp.ToolCallContent)) + case ContentTypeToolResult: + return enc.Encode(body(cp.ToolResultContent)) + case ContentTypeResource: + return enc.Encode(body(cp.ResourceContent)) + case ContentTypeResourceRef: + return enc.Encode(body(cp.ResourceRefContent)) + case ContentTypePromptRequest: + return enc.Encode(body(cp.PromptRequestContent)) + case ContentTypePromptResult: + return enc.Encode(body(cp.PromptResultContent)) + case ContentTypeImage: + return enc.Encode(body(cp.ImageContent)) + case ContentTypeVideo: + return enc.Encode(body(cp.VideoContent)) + case ContentTypeAudio: + return enc.Encode(body(cp.AudioContent)) + case ContentTypeDocument: + return enc.Encode(body(cp.DocumentContent)) + default: + // Unknown content_type. If we captured the raw wire form on + // decode (forward-compat path), emit it verbatim so we don't + // lose data on round-trip. Otherwise fall back to a minimal + // content_type-only message (a Go-side construction with an + // unrecognized ContentType โ€” rare). + if cp.rawMap != nil { + return enc.Encode(cp.rawMap) + } + out := map[string]any{wireKeyContentType: cp.ContentType} + if cp.Text != "" { + out[wireKeyText] = cp.Text + } + return enc.Encode(out) + } +} + +// DecodeMsgpack reads the tagged-union wire format. +func (cp *ContentPart) DecodeMsgpack(dec *msgpack.Decoder) error { + var raw map[string]any + if err := dec.Decode(&raw); err != nil { + return err + } + + if ct, ok := raw[wireKeyContentType].(string); ok { + cp.ContentType = ct + } + + switch cp.ContentType { + case ContentTypeText, ContentTypeThinking: + if t, ok := raw[wireKeyText].(string); ok { + cp.Text = t + } + case ContentTypeToolCall: + cp.ToolCallContent = decodeAs[ToolCall](raw[wireKeyContent]) + case ContentTypeToolResult: + cp.ToolResultContent = decodeAs[ToolResult](raw[wireKeyContent]) + case ContentTypeResource: + cp.ResourceContent = decodeAs[Resource](raw[wireKeyContent]) + case ContentTypeResourceRef: + cp.ResourceRefContent = decodeAs[ResourceReference](raw[wireKeyContent]) + case ContentTypePromptRequest: + cp.PromptRequestContent = decodeAs[PromptRequest](raw[wireKeyContent]) + case ContentTypePromptResult: + cp.PromptResultContent = decodeAs[PromptResult](raw[wireKeyContent]) + case ContentTypeImage: + cp.ImageContent = decodeAs[ImageSource](raw[wireKeyContent]) + case ContentTypeVideo: + cp.VideoContent = decodeAs[VideoSource](raw[wireKeyContent]) + case ContentTypeAudio: + cp.AudioContent = decodeAs[AudioSource](raw[wireKeyContent]) + case ContentTypeDocument: + cp.DocumentContent = decodeAs[DocumentSource](raw[wireKeyContent]) + default: + // Unknown content_type โ€” preserve the full wire form so + // EncodeMsgpack can pass it through unchanged. Forward + // compat for newer Rust variants the Go SDK doesn't know + // about yet (P2 #17). + cp.rawMap = raw + } + + return nil +} + +// --------------------------------------------------------------------------- +// Content Part Constructors +// --------------------------------------------------------------------------- + +// Constructor functions are named `NewXPart` to avoid shadowing the +// matching `XContent` field on ContentPart. Previously a constructor +// like `ToolCallContent(tc)` had the same name as the field +// `cp.ToolCallContent` โ€” confusing in code and hostile to IDE +// autocomplete. The `New*Part` form mirrors common Go conventions +// (`NewClient`, `NewBuffer`). + +// NewTextPart creates a text content part. +func NewTextPart(text string) ContentPart { + return ContentPart{ContentType: ContentTypeText, Text: text} +} + +// NewThinkingPart creates a thinking content part. +func NewThinkingPart(text string) ContentPart { + return ContentPart{ContentType: ContentTypeThinking, Text: text} +} + +// NewToolCallPart creates a tool_call content part. +func NewToolCallPart(tc ToolCall) ContentPart { + return ContentPart{ContentType: ContentTypeToolCall, ToolCallContent: &tc} +} + +// NewToolResultPart creates a tool_result content part. +func NewToolResultPart(tr ToolResult) ContentPart { + return ContentPart{ContentType: ContentTypeToolResult, ToolResultContent: &tr} +} + +// NewResourcePart creates a resource content part. +func NewResourcePart(r Resource) ContentPart { + return ContentPart{ContentType: ContentTypeResource, ResourceContent: &r} +} + +// NewResourceRefPart creates a resource_ref content part. +func NewResourceRefPart(r ResourceReference) ContentPart { + return ContentPart{ContentType: ContentTypeResourceRef, ResourceRefContent: &r} +} + +// NewPromptRequestPart creates a prompt_request content part. +func NewPromptRequestPart(pr PromptRequest) ContentPart { + return ContentPart{ContentType: ContentTypePromptRequest, PromptRequestContent: &pr} +} + +// NewPromptResultPart creates a prompt_result content part. +func NewPromptResultPart(pr PromptResult) ContentPart { + return ContentPart{ContentType: ContentTypePromptResult, PromptResultContent: &pr} +} + +// NewImagePart creates an image content part. +func NewImagePart(img ImageSource) ContentPart { + return ContentPart{ContentType: ContentTypeImage, ImageContent: &img} +} + +// NewVideoPart creates a video content part. +func NewVideoPart(vid VideoSource) ContentPart { + return ContentPart{ContentType: ContentTypeVideo, VideoContent: &vid} +} + +// NewAudioPart creates an audio content part. +func NewAudioPart(aud AudioSource) ContentPart { + return ContentPart{ContentType: ContentTypeAudio, AudioContent: &aud} +} + +// NewDocumentPart creates a document content part. +func NewDocumentPart(doc DocumentSource) ContentPart { + return ContentPart{ContentType: ContentTypeDocument, DocumentContent: &doc} +} + +// --------------------------------------------------------------------------- +// Domain Objects +// --------------------------------------------------------------------------- + +// ToolCall represents a tool invocation request. +type ToolCall struct { + ToolCallID string `msgpack:"tool_call_id"` + Name string `msgpack:"name"` + Arguments map[string]any `msgpack:"arguments,omitempty"` + Namespace string `msgpack:"namespace,omitempty"` +} + +// ToolResult represents the output of a tool execution. +type ToolResult struct { + ToolCallID string `msgpack:"tool_call_id"` + ToolName string `msgpack:"tool_name"` + Content any `msgpack:"content,omitempty"` + IsError bool `msgpack:"is_error,omitempty"` +} + +// Resource represents an embedded resource with content (MCP). +type Resource struct { + ResourceRequestID string `msgpack:"resource_request_id"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + Description string `msgpack:"description,omitempty"` + ResourceType string `msgpack:"resource_type"` + Content string `msgpack:"content,omitempty"` + Blob []byte `msgpack:"blob,omitempty"` + MimeType string `msgpack:"mime_type,omitempty"` + SizeBytes *uint64 `msgpack:"size_bytes,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` + Version string `msgpack:"version,omitempty"` +} + +// ResourceReference is a lightweight resource reference without content. +type ResourceReference struct { + ResourceRequestID string `msgpack:"resource_request_id"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + ResourceType string `msgpack:"resource_type"` + RangeStart *uint64 `msgpack:"range_start,omitempty"` + RangeEnd *uint64 `msgpack:"range_end,omitempty"` + Selector string `msgpack:"selector,omitempty"` +} + +// PromptRequest represents a prompt template invocation request (MCP). +type PromptRequest struct { + PromptRequestID string `msgpack:"prompt_request_id"` + Name string `msgpack:"name"` + Arguments map[string]any `msgpack:"arguments,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// PromptResult represents a rendered prompt template result. +type PromptResult struct { + PromptRequestID string `msgpack:"prompt_request_id"` + PromptName string `msgpack:"prompt_name"` + Messages []Message `msgpack:"messages,omitempty"` + Content string `msgpack:"content,omitempty"` + IsError bool `msgpack:"is_error,omitempty"` + ErrorMessage string `msgpack:"error_message,omitempty"` +} + +// --------------------------------------------------------------------------- +// Media Source Types +// --------------------------------------------------------------------------- + +// ImageSource holds image data (URL or base64). +type ImageSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` +} + +// VideoSource holds video data (URL or base64). +type VideoSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + DurationMs *uint64 `msgpack:"duration_ms,omitempty"` +} + +// AudioSource holds audio data (URL or base64). +type AudioSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + DurationMs *uint64 `msgpack:"duration_ms,omitempty"` +} + +// DocumentSource holds document data (URL or base64). +type DocumentSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + Title string `msgpack:"title,omitempty"` +} + +// --------------------------------------------------------------------------- +// Decode helpers โ€” extract typed domain objects from a decoded `any` value. +// --------------------------------------------------------------------------- + +// decodeAs re-encodes a decoded msgpack value and unmarshals it into a +// typed struct, letting the struct's msgpack tags drive field selection. +// Replaces 11 hand-rolled decoders that each had to enumerate fields +// manually โ€” that pattern was the source of the silent data loss +// reviewer flagged in #13 (`DurationMs`, `RangeStart/End`, `Blob`, +// `SizeBytes`, `Messages` were all dropped). Adding a new field to a +// struct now Just Works without a corresponding decoder edit. +// +// Cost: an extra msgpack marshal + unmarshal per content part. This is +// on the per-message decode path, not per-pipeline-step. msgpack is +// fast; in practice it's microseconds. If this ever shows up on a hot +// path we can switch to msgpack's `Decoder.Query()` or hand-roll +// targeted decoders for specific high-volume types. +func decodeAs[T any](v any) *T { + if v == nil { + return nil + } + bytes, err := msgpack.Marshal(v) + if err != nil { + return nil + } + var out T + if err := msgpack.Unmarshal(bytes, &out); err != nil { + return nil + } + return &out +} diff --git a/go/cpex/cmf_test.go b/go/cpex/cmf_test.go new file mode 100644 index 00000000..e329aabe --- /dev/null +++ b/go/cpex/cmf_test.go @@ -0,0 +1,262 @@ +// Location: ./go/cpex/cmf_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MessagePack roundtrip coverage for the CMF tagged-union ContentPart. +// +// Pass 4 of the CGO review fixed an audit's worth of dropped fields +// across the per-variant decoders (DurationMs, RangeStart/End, Blob, +// SizeBytes, Messages, etc.) and rewrote them on top of a generic +// `decodeAs[T]` helper. These tests pin that fix: every variant must +// roundtrip msgpack with all its fields intact, and unknown variants +// must passthrough via the rawMap path. + +package cpex + +import ( + "reflect" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +// roundTripContentPart encodes via ContentPart.EncodeMsgpack and +// decodes via ContentPart.DecodeMsgpack โ€” the same path the FFI +// uses on either side of the boundary. Returns the decoded value +// for the caller to deep-compare. +func roundTripContentPart(t *testing.T, original ContentPart) ContentPart { + t.Helper() + bytes, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(bytes, &decoded); err != nil { + t.Fatalf("decode failed: %v", err) + } + return decoded +} + +func u64ptr(v uint64) *uint64 { return &v } + +// Each subtest below builds a fully-populated variant โ€” every field +// present, including the ones Pass 4's review found dropped โ€” and +// asserts roundtrip equality via reflect.DeepEqual. A missing field +// in either the encoder or decoder produces a diff and a clean +// failure pointing at the variant. + +func TestContentPart_RoundTripText(t *testing.T) { + original := NewTextPart("hello world") + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("text roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripThinking(t *testing.T) { + original := NewThinkingPart("internal monologue") + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("thinking roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripToolCall(t *testing.T) { + original := NewToolCallPart(ToolCall{ + ToolCallID: "call-1", + Name: "search", + Arguments: map[string]any{"q": "anthropic", "limit": int64(10)}, + Namespace: "tools.web", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("tool_call roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripToolResult(t *testing.T) { + original := NewToolResultPart(ToolResult{ + ToolCallID: "call-1", + ToolName: "search", + Content: "result body", + IsError: false, + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("tool_result roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripResource(t *testing.T) { + // All Pass 4-restored fields populated: Blob, SizeBytes, plus the + // existing ones. If decodeAs[Resource] drops any of these the + // reflect.DeepEqual catches it. + original := NewResourcePart(Resource{ + ResourceRequestID: "req-1", + URI: "file://x", + Name: "x.txt", + Description: "a file", + ResourceType: "text", + Content: "body", + Blob: []byte{0x01, 0x02, 0x03}, + MimeType: "text/plain", + SizeBytes: u64ptr(3), + Annotations: map[string]any{"tag": "v1"}, + Version: "v1", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("resource roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripResourceRef(t *testing.T) { + // RangeStart/RangeEnd were both dropped pre-Pass 4 โ€” explicit fields here. + original := NewResourceRefPart(ResourceReference{ + ResourceRequestID: "req-2", + URI: "file://y", + Name: "y.txt", + ResourceType: "text", + RangeStart: u64ptr(0), + RangeEnd: u64ptr(100), + Selector: "$.body", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("resource_ref roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripPromptRequest(t *testing.T) { + original := NewPromptRequestPart(PromptRequest{ + PromptRequestID: "pr-1", + Name: "summarize", + Arguments: map[string]any{"length": "short"}, + ServerID: "srv-1", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("prompt_request roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripPromptResult(t *testing.T) { + // Messages was dropped entirely pre-Pass 4. Populating it here + // pins the fix. + original := NewPromptResultPart(PromptResult{ + PromptRequestID: "pr-1", + PromptName: "summarize", + Messages: []Message{ + NewMessage("user", NewTextPart("input")), + NewMessage("assistant", NewTextPart("output")), + }, + Content: "summary text", + IsError: false, + ErrorMessage: "", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("prompt_result roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripImage(t *testing.T) { + original := NewImagePart(ImageSource{ + SourceType: "base64", + Data: "aW1hZ2U=", + MediaType: "image/png", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("image roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripVideo(t *testing.T) { + // DurationMs was dropped pre-Pass 4 โ€” explicit field here. + original := NewVideoPart(VideoSource{ + SourceType: "url", + Data: "https://example/v.mp4", + MediaType: "video/mp4", + DurationMs: u64ptr(15000), + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("video roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripAudio(t *testing.T) { + // Same DurationMs drop โ€” explicit field. + original := NewAudioPart(AudioSource{ + SourceType: "base64", + Data: "YXVkaW8=", + MediaType: "audio/mp3", + DurationMs: u64ptr(5500), + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("audio roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripDocument(t *testing.T) { + original := NewDocumentPart(DocumentSource{ + SourceType: "url", + Data: "https://example/d.pdf", + MediaType: "application/pdf", + Title: "doc", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("document roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +// Forward-compat: a content_type the Go decoder doesn't recognize +// must passthrough via rawMap so that re-encoding produces the same +// wire bytes. This protects against silent drops when Rust adds a +// variant that Go hasn't been updated for yet. +func TestContentPart_UnknownVariantPassesThrough(t *testing.T) { + // Build a payload by encoding a known structure with an + // unrecognized content_type tag โ€” simulate Rust shipping a future + // variant. + wireMap := map[string]any{ + "content_type": "future_variant_v2", + "content": map[string]any{ + "foo": "bar", + "n": int64(42), + }, + } + originalBytes, err := msgpack.Marshal(wireMap) + if err != nil { + t.Fatalf("encode wire fixture failed: %v", err) + } + + var cp ContentPart + if err := msgpack.Unmarshal(originalBytes, &cp); err != nil { + t.Fatalf("decode unknown variant failed: %v", err) + } + + // Re-encode and compare to the original wire bytes. The tag and + // the body must both survive intact โ€” that's what rawMap is for. + roundTripBytes, err := msgpack.Marshal(cp) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + + // Decode both sides into generic maps to compare semantically + // (msgpack key ordering is not guaranteed across encode passes). + var originalMap, roundTripMap map[string]any + if err := msgpack.Unmarshal(originalBytes, &originalMap); err != nil { + t.Fatalf("decode original to map: %v", err) + } + if err := msgpack.Unmarshal(roundTripBytes, &roundTripMap); err != nil { + t.Fatalf("decode roundtrip to map: %v", err) + } + if !reflect.DeepEqual(originalMap, roundTripMap) { + t.Errorf("unknown variant passthrough mismatch:\n want: %+v\n got: %+v", + originalMap, roundTripMap) + } +} diff --git a/go/cpex/constants.go b/go/cpex/constants.go new file mode 100644 index 00000000..45ce3858 --- /dev/null +++ b/go/cpex/constants.go @@ -0,0 +1,66 @@ +// Location: ./go/cpex/constants.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Wire-format constants used across the encoder, decoder, and constructor +// helpers. Centralizing them prevents the silent mismatches you'd get from +// a typo in one of the three sites a value would otherwise be duplicated. +// +// All values match Rust's serde tag/field names exactly โ€” adding a new +// variant requires syncing this file with the corresponding Rust enum. + +package cpex + +// Payload type IDs โ€” must match Rust's PAYLOAD_* constants in +// crates/cpex-ffi/src/lib.rs. Used as the `payload_type` discriminator +// when crossing the FFI boundary. +const ( + // PayloadGeneric is a generic JSON-like payload (map[string]any). + PayloadGeneric uint8 = 0 + // PayloadCMFMessage is a CMF MessagePayload. + PayloadCMFMessage uint8 = 1 +) + +// ContentType values โ€” the discriminator for ContentPart's tagged union. +// Wire-compatible with Rust's `#[serde(tag = "content_type")]` enum in +// `crates/cpex-core/src/cmf/`. Every string literal in cmf.go's encoder / +// decoder / constructor switches resolves to one of these. +const ( + ContentTypeText = "text" + ContentTypeThinking = "thinking" + ContentTypeToolCall = "tool_call" + ContentTypeToolResult = "tool_result" + ContentTypeResource = "resource" + ContentTypeResourceRef = "resource_ref" + ContentTypePromptRequest = "prompt_request" + ContentTypePromptResult = "prompt_result" + ContentTypeImage = "image" + ContentTypeVideo = "video" + ContentTypeAudio = "audio" + ContentTypeDocument = "document" +) + +// Wire-format keys for the ContentPart tagged-union envelope. Unexported +// because they're an internal serialization detail โ€” users build +// ContentPart via the constructors (NewTextPart, NewToolCallPart, โ€ฆ) +// and never touch the wire keys directly. +const ( + wireKeyContentType = "content_type" + wireKeyContent = "content" + wireKeyText = "text" +) + +// FFI return codes from libcpex_ffi. 0 means success; negative codes +// classify the failure. Stable wire ABI with the Rust side โ€” values must +// match `RC_*` constants in `crates/cpex-ffi/src/lib.rs`. Don't renumber. +const ( + rcOK = 0 + rcInvalidHandle = -1 + rcInvalidInput = -2 + rcParseError = -3 + rcPipelineError = -4 + rcSerializeError = -5 + rcTimeout = -6 + rcPanic = -7 +) diff --git a/go/cpex/errors.go b/go/cpex/errors.go new file mode 100644 index 00000000..4a193ae6 --- /dev/null +++ b/go/cpex/errors.go @@ -0,0 +1,88 @@ +// Location: ./go/cpex/errors.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Sentinel errors for FFI return-code classification. +// +// The libcpex_ffi C API returns int codes (0 = success, negative = +// failure). Each negative code maps to a stable sentinel error here โ€” +// callers can use `errors.Is(err, ErrCpexTimeout)` to handle specific +// failure modes (retry on timeout, abort on panic, etc.) instead of +// regex-matching opaque "invoke failed" strings. + +package cpex + +import ( + "errors" + "fmt" +) + +// Sentinel errors returned by the FFI wrapper. Compare with `errors.Is`. +// All errors returned from PluginManager / BackgroundTasks methods that +// originated in a non-zero FFI return code wrap one of these. +var ( + // ErrCpexInvalidHandle: the manager handle is null or has been + // shutdown. Trying to use a manager after Shutdown produces this. + ErrCpexInvalidHandle = errors.New("cpex: invalid handle (manager null or shutdown)") + + // ErrCpexInvalidInput: caller-supplied input was malformed โ€” bad + // UTF-8, null pointer where data was required, oversized buffer, + // unknown payload type. Caller bug; fix the input and retry. + ErrCpexInvalidInput = errors.New("cpex: invalid input") + + // ErrCpexParse: parse / deserialize step failed (YAML config, + // MessagePack payload, MessagePack extensions). Caller bug; fix + // the data shape and retry. + ErrCpexParse = errors.New("cpex: parse / deserialize failed") + + // ErrCpexPipeline: pipeline / lifecycle step failed โ€” load_config + // returned Err, initialize failed, or a plugin signalled a + // runtime failure that wasn't a timeout or panic. + ErrCpexPipeline = errors.New("cpex: pipeline / lifecycle error") + + // ErrCpexSerialize: result serialization failed after the pipeline + // ran successfully. Usually OOM on rmp_serde::to_vec_named or an + // unserializable JSON value. Rare; not retryable on its own. + ErrCpexSerialize = errors.New("cpex: result serialize failed") + + // ErrCpexTimeout: wall-clock timeout exceeded inside the FFI + // boundary. The plugin is likely CPU-bound or blocking the OS + // thread without yielding (per-plugin tokio timeouts can't catch + // non-cooperative work). Caller may retry but probably wants to + // disable the offending plugin first. + ErrCpexTimeout = errors.New("cpex: wall-clock timeout exceeded") + + // ErrCpexPanic: a plugin panicked across the FFI boundary; the + // panic was caught (preventing UB / process abort) but the + // invocation is lost. Same plugin will likely panic again. + ErrCpexPanic = errors.New("cpex: plugin panicked at FFI boundary") +) + +// errorFromRC maps an FFI return code to a typed error. `op` is included +// in the wrapped message so the caller can tell which operation failed +// without losing the sentinel for `errors.Is` checks. +func errorFromRC(rc int, op string) error { + switch rc { + case rcOK: + return nil + case rcInvalidHandle: + return fmt.Errorf("%s: %w", op, ErrCpexInvalidHandle) + case rcInvalidInput: + return fmt.Errorf("%s: %w", op, ErrCpexInvalidInput) + case rcParseError: + return fmt.Errorf("%s: %w", op, ErrCpexParse) + case rcPipelineError: + return fmt.Errorf("%s: %w", op, ErrCpexPipeline) + case rcSerializeError: + return fmt.Errorf("%s: %w", op, ErrCpexSerialize) + case rcTimeout: + return fmt.Errorf("%s: %w", op, ErrCpexTimeout) + case rcPanic: + return fmt.Errorf("%s: %w", op, ErrCpexPanic) + default: + // Unknown code โ€” wrap a generic error including the rc so + // the caller can at least see the raw value. + return fmt.Errorf("%s: cpex: unknown FFI return code %d", op, rc) + } +} diff --git a/go/cpex/ffi.go b/go/cpex/ffi.go new file mode 100644 index 00000000..67e9c848 --- /dev/null +++ b/go/cpex/ffi.go @@ -0,0 +1,66 @@ +// Location: ./go/cpex/ffi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CGO declarations for the CPEX FFI layer. +// +// Declares the C function signatures from libcpex_ffi. These are +// opaque handles โ€” Go callers use the PluginManager wrapper in +// manager.go rather than calling these directly. + +package cpex + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi + +#include +#include + +// Opaque handles +typedef void* CpexManager; +typedef void* CpexContextTable; +typedef void* CpexBackgroundTasks; + +// Runtime configuration +int cpex_configure_runtime(int worker_threads); + +// Manager lifecycle +CpexManager cpex_manager_new(const char* config_yaml, int config_len); +CpexManager cpex_manager_new_default(); +int cpex_load_config(CpexManager mgr, const char* config_yaml, int config_len); +int cpex_initialize(CpexManager mgr); +void cpex_shutdown(CpexManager mgr); + +// Query +int cpex_has_hooks_for(CpexManager mgr, const char* hook_name, int hook_len); +int cpex_plugin_count(CpexManager mgr); +int cpex_is_initialized(CpexManager mgr); +int cpex_plugin_names(CpexManager mgr, uint8_t** names_msgpack_out, int* names_len_out); + +// Invoke +int cpex_invoke( + CpexManager mgr, + const char* hook_name, int hook_len, + uint8_t payload_type, + const uint8_t* payload_msgpack, int payload_len, + const uint8_t* extensions_msgpack, int extensions_len, + CpexContextTable context_table, + uint8_t** result_msgpack_out, int* result_len_out, + CpexContextTable* context_table_out, + CpexBackgroundTasks* bg_handle_out +); + +// Background tasks +int cpex_wait_background( + CpexManager mgr, + CpexBackgroundTasks bg_handle, + uint8_t** errors_msgpack_out, int* errors_len_out +); +void cpex_free_background(CpexBackgroundTasks bg_handle); + +// Memory +void cpex_free_context_table(CpexContextTable ct); +void cpex_free_bytes(uint8_t* ptr, int len); +*/ +import "C" diff --git a/go/cpex/go.mod b/go/cpex/go.mod new file mode 100644 index 00000000..d71e10b0 --- /dev/null +++ b/go/cpex/go.mod @@ -0,0 +1,8 @@ +module github.com/contextforge-org/contextforge-plugins-framework/go/cpex + +go 1.25.4 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) diff --git a/go/cpex/go.sum b/go/cpex/go.sum new file mode 100644 index 00000000..84eba6c9 --- /dev/null +++ b/go/cpex/go.sum @@ -0,0 +1,4 @@ +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= diff --git a/go/cpex/manager.go b/go/cpex/manager.go new file mode 100644 index 00000000..911bb7ec --- /dev/null +++ b/go/cpex/manager.go @@ -0,0 +1,550 @@ +// Location: ./go/cpex/manager.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// PluginManager โ€” Go wrapper for the CPEX plugin runtime. +// +// Owns the lifecycle of the Rust PluginManager via cgo. Provides +// the public API that Go host systems call to register factories, +// load config, initialize plugins, and invoke hooks. +// +// Lifecycle: +// +// NewPluginManagerDefault() โ†’ RegisterFactories() โ†’ LoadConfig() โ†’ Initialize() โ†’ InvokeByName() โ†’ Shutdown() +// +// Payloads and extensions are serialized to MessagePack when +// crossing the FFI boundary. ContextTable and BackgroundTasks +// are opaque handles to Rust-owned data. + +package cpex + +import ( + "errors" + "fmt" + "runtime" + "sync" + "unsafe" + + "github.com/vmihailenco/msgpack/v5" +) + +/* +#include +#include + +// Opaque handles +typedef void* CpexManager; +typedef void* CpexContextTable; +typedef void* CpexBackgroundTasks; + +// Extern declarations - implemented in libcpex_ffi. +// +// These are duplicated with ffi.go's preamble. CGO does NOT merge +// declarations across multiple files' preambles in a single package - +// each file's `import "C"` resolves only against its own preceding +// comment block. The reviewer's "remove these, CGO resolves +// package-wide" suggestion was tested and didn't work; CGO reports +// "could not determine what C.cpex_X refers to" for any function +// declared only in a sibling file's preamble. +// +// If you change a signature, edit BOTH this block and ffi.go's. The +// build will fail loudly on a mismatch (Go's C type system catches +// it), but the duplication is unavoidable until either: +// - all cgo entry points move to ffi.go (refactor), or +// - we generate the C header via cbindgen and #include it from +// both files. +extern int cpex_configure_runtime(int worker_threads); +extern CpexManager cpex_manager_new(const char* config_yaml, int config_len); +extern CpexManager cpex_manager_new_default(); +extern int cpex_load_config(CpexManager mgr, const char* config_yaml, int config_len); +extern int cpex_initialize(CpexManager mgr); +extern void cpex_shutdown(CpexManager mgr); +extern int cpex_has_hooks_for(CpexManager mgr, const char* hook_name, int hook_len); +extern int cpex_plugin_count(CpexManager mgr); +extern int cpex_is_initialized(CpexManager mgr); +extern int cpex_plugin_names(CpexManager mgr, uint8_t** names_msgpack_out, int* names_len_out); +extern int cpex_invoke( + CpexManager mgr, + const char* hook_name, int hook_len, + uint8_t payload_type, + const uint8_t* payload_msgpack, int payload_len, + const uint8_t* extensions_msgpack, int extensions_len, + CpexContextTable context_table, + uint8_t** result_msgpack_out, int* result_len_out, + CpexContextTable* context_table_out, + CpexBackgroundTasks* bg_handle_out +); +extern int cpex_wait_background( + CpexManager mgr, + CpexBackgroundTasks bg_handle, + uint8_t** errors_msgpack_out, int* errors_len_out +); +extern void cpex_free_background(CpexBackgroundTasks bg_handle); +extern void cpex_free_context_table(CpexContextTable ct); +extern void cpex_free_bytes(uint8_t* ptr, int len); +*/ +import "C" + +// PluginManager manages the lifecycle of CPEX plugins and hook dispatch. +// Wraps the Rust PluginManager โ€” all plugin execution happens in Rust. +// +// Concurrency: `mu` serializes lifecycle (Shutdown, finalizer) against +// in-flight cgo calls. Operations (Invoke, Initialize, queries) take +// the *read* lock and may run in parallel with each other โ€” the +// underlying Rust API is `&self` and ArcSwap-backed, so concurrent +// dispatch is safe. `Shutdown` and the GC finalizer take the *write* +// lock so the C handle can't be freed while a cgo call is mid-flight. +type PluginManager struct { + mu sync.RWMutex + handle C.CpexManager // protected by mu; nil after Shutdown +} + +// ContextTable holds per-plugin context state across hook invocations. +// Opaque handle to Rust-owned data โ€” not serialized. +type ContextTable struct { + handle C.CpexContextTable +} + +// BackgroundTasks holds fire-and-forget task handles. +// Opaque handle to Rust-owned data โ€” not serialized. +// +// Holds *PluginManager (not the raw C handle) so `Wait()` can check +// `mgr.handle != nil` under the manager's RWMutex โ€” preventing a +// use-after-free if the manager was Shutdown after the invoke that +// produced this `BackgroundTasks`. +type BackgroundTasks struct { + handle C.CpexBackgroundTasks + mgr *PluginManager +} + +// ConfigureRuntime sets the worker thread count for the shared tokio +// runtime that backs every PluginManager in the process. Must be +// called before the first NewPluginManager โ€” once a manager has +// been created the runtime is fixed for the process lifetime. +// +// Precedence: ConfigureRuntime > CPEX_FFI_WORKER_THREADS env var > +// num_cpus default. Returns ErrCpexInvalidInput if workerThreads <= 0 +// or the runtime has already been initialized. +func ConfigureRuntime(workerThreads int) error { + rc := C.cpex_configure_runtime(C.int(workerThreads)) + return errorFromRC(int(rc), "ConfigureRuntime") +} + +// finalizeManager is the GC fallback path when the caller forgot to +// call Shutdown. Takes the write lock so it can't race with an +// explicit Shutdown that's already running. +func finalizeManager(m *PluginManager) { + m.mu.Lock() + defer m.mu.Unlock() + if m.handle != nil { + C.cpex_shutdown(m.handle) + m.handle = nil + } +} + +// NewPluginManager creates a manager from a YAML config string. +// Built-in Rust plugin factories are registered automatically. +func NewPluginManager(yaml string) (*PluginManager, error) { + cYaml := C.CString(yaml) + defer C.free(unsafe.Pointer(cYaml)) + + handle := C.cpex_manager_new(cYaml, C.int(len(yaml))) + if handle == nil { + return nil, errors.New("cpex: failed to create plugin manager from config") + } + + mgr := &PluginManager{handle: handle} + runtime.SetFinalizer(mgr, finalizeManager) + return mgr, nil +} + +// NewPluginManagerDefault creates a manager with default config. +// Useful when registering plugins programmatically. +func NewPluginManagerDefault() (*PluginManager, error) { + handle := C.cpex_manager_new_default() + if handle == nil { + return nil, errors.New("cpex: failed to create default plugin manager") + } + + mgr := &PluginManager{handle: handle} + runtime.SetFinalizer(mgr, finalizeManager) + return mgr, nil +} + +// FactoryRegistrar is a function that registers plugin factories on the +// manager's internal handle. The handle is an opaque C pointer โ€” callers +// pass it to their own extern C registration function. +type FactoryRegistrar func(handle unsafe.Pointer) error + +// RegisterFactories calls fn with the manager's internal C handle, +// allowing callers to register plugin factories via their own FFI. +// Must be called before LoadConfig. +func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("RegisterFactories: %w", ErrCpexInvalidHandle) + } + return fn(unsafe.Pointer(m.handle)) +} + +// LoadConfig loads a YAML config string into the manager. +// Factories must be registered before calling this method. +// +// On failure, the returned error wraps one of the typed sentinels +// (ErrCpexInvalidHandle, ErrCpexInvalidInput, ErrCpexParse, +// ErrCpexPipeline, ErrCpexPanic). Use `errors.Is` to classify. +func (m *PluginManager) LoadConfig(yaml string) error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("LoadConfig: %w", ErrCpexInvalidHandle) + } + + cYaml := C.CString(yaml) + defer C.free(unsafe.Pointer(cYaml)) + + rc := C.cpex_load_config(m.handle, cYaml, C.int(len(yaml))) + return errorFromRC(int(rc), "LoadConfig") +} + +// Initialize calls Initialize on all registered plugins. +// Must be called before invoking any hooks. +// +// On failure, the returned error wraps one of the typed sentinels +// (ErrCpexInvalidHandle, ErrCpexPipeline, ErrCpexTimeout, ErrCpexPanic). +func (m *PluginManager) Initialize() error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("Initialize: %w", ErrCpexInvalidHandle) + } + + rc := C.cpex_initialize(m.handle) + return errorFromRC(int(rc), "Initialize") +} + +// Shutdown gracefully shuts down all plugins and releases resources. +// After this call, the manager is invalid and must not be used. +// +// Takes the write lock to ensure no in-flight cgo call is racing with +// the destruction of the C handle. Also clears the GC finalizer so +// the finalizer can't fire later and double-free. +func (m *PluginManager) Shutdown() { + m.mu.Lock() + defer m.mu.Unlock() + if m.handle == nil { + return + } + // Clear the finalizer first โ€” if cpex_shutdown panics or aborts, + // we still don't want the finalizer to run later and try again. + runtime.SetFinalizer(m, nil) + C.cpex_shutdown(m.handle) + m.handle = nil +} + +// HasHooksFor returns true if any plugins are registered for the hook. +// No serialization โ€” just a hash lookup across the FFI boundary. +func (m *PluginManager) HasHooksFor(hookName string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return false + } + cName := C.CString(hookName) + defer C.free(unsafe.Pointer(cName)) + return C.cpex_has_hooks_for(m.handle, cName, C.int(len(hookName))) == 1 +} + +// PluginCount returns the number of registered plugins. +func (m *PluginManager) PluginCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return 0 + } + return int(C.cpex_plugin_count(m.handle)) +} + +// IsInitialized reports whether Initialize has been called and Shutdown +// has not. Useful for agent control loops that may inspect manager +// state before deciding whether to dispatch. +func (m *PluginManager) IsInitialized() bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return false + } + return C.cpex_is_initialized(m.handle) == 1 +} + +// PluginNames returns the names of all registered plugins. Order is +// not stable across calls โ€” the underlying registry uses a HashMap. +func (m *PluginManager) PluginNames() ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, fmt.Errorf("PluginNames: %w", ErrCpexInvalidHandle) + } + + var namesPtr *C.uint8_t + var namesLen C.int + rc := C.cpex_plugin_names(m.handle, &namesPtr, &namesLen) + if rc != 0 { + return nil, errorFromRC(int(rc), "PluginNames") + } + + bytes := C.GoBytes(unsafe.Pointer(namesPtr), namesLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(namesPtr)), namesLen) + + var names []string + if err := msgpack.Unmarshal(bytes, &names); err != nil { + return nil, fmt.Errorf("PluginNames: decode failed: %w", err) + } + return names, nil +} + +// InvokeByName invokes a hook by name with a payload and extensions. +// Payload and extensions are serialized to MessagePack internally. +// The ContextTable is an opaque handle โ€” pass nil on the first call, +// then thread result's ContextTable into subsequent calls. +func (m *PluginManager) InvokeByName( + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, nil, nil, fmt.Errorf("InvokeByName: %w", ErrCpexInvalidHandle) + } + + // Serialize payload to MessagePack + payloadBytes, err := msgpack.Marshal(payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: payload marshal failed: %w", err) + } + + // Serialize extensions to MessagePack + var extBytes []byte + if extensions != nil { + extBytes, err = msgpack.Marshal(extensions) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: extensions marshal failed: %w", err) + } + } + + // Prepare C args + cHookName := C.CString(hookName) + defer C.free(unsafe.Pointer(cHookName)) + + // Pass the context-table handle to Rust but DO NOT nil our local + // reference until we know Rust succeeded. Rust consumes the handle + // only at the moment of invoke (after all input validation), so + // pre-invoke failures (bad payload, bad extensions, etc.) leave + // the handle untouched and the caller's ContextTable remains valid. + // + // Caveat: on a post-invoke failure (rare โ€” only result-serialization + // OOM), Rust has consumed the box but doesn't write ctOut, so the + // caller's ContextTable handle becomes dangling. The caller should + // not reuse a ContextTable after an InvokeByName error. + var ctHandle C.CpexContextTable + if contextTable != nil { + ctHandle = contextTable.handle + } + + var resultPtr *C.uint8_t + var resultLen C.int + var ctOut C.CpexContextTable + var bgOut C.CpexBackgroundTasks + + var payloadPtr *C.uint8_t + if len(payloadBytes) > 0 { + payloadPtr = (*C.uint8_t)(unsafe.Pointer(&payloadBytes[0])) + } + + var extPtr *C.uint8_t + var extLen C.int + if len(extBytes) > 0 { + extPtr = (*C.uint8_t)(unsafe.Pointer(&extBytes[0])) + extLen = C.int(len(extBytes)) + } + + rc := C.cpex_invoke( + m.handle, + cHookName, C.int(len(hookName)), + C.uint8_t(payloadType), + payloadPtr, C.int(len(payloadBytes)), + extPtr, extLen, + ctHandle, + &resultPtr, &resultLen, + &ctOut, + &bgOut, + ) + + if rc != 0 { + return nil, nil, nil, errorFromRC(int(rc), "InvokeByName") + } + + // Rust succeeded โ€” it consumed ctHandle and produced ctOut. + // NOW it's safe to nil the caller's reference (the original Box + // was consumed by Rust; its successor is in ctOut). + if contextTable != nil { + contextTable.handle = nil + } + + // Deserialize result from MessagePack + resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) + + var result PipelineResult + if err := msgpack.Unmarshal(resultBytes, &result); err != nil { + return nil, nil, nil, fmt.Errorf("cpex: result unmarshal failed: %w", err) + } + + // Wrap opaque handles + resultCT := &ContextTable{handle: ctOut} + runtime.SetFinalizer(resultCT, func(ct *ContextTable) { + ct.Close() + }) + + // Hold *PluginManager (not the raw C handle) so Wait() can check + // mgr.handle != nil under the manager's mutex โ€” preventing UAF + // if Shutdown is called between this invoke and Wait(). + bg := &BackgroundTasks{handle: bgOut, mgr: m} + + return &result, resultCT, bg, nil +} + +// Invoke is the typed invoke path. Calls InvokeByName and deserializes +// the modified payload and extensions into concrete Go types. +// +// Example: +// +// result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( +// mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, +// payload, ext, nil, +// ) +// if !result.IsDenied() && result.ModifiedPayload != nil { +// fmt.Println(result.ModifiedPayload.Message.Role) +// } +func Invoke[P any]( + m *PluginManager, + hookName string, + payloadType uint8, + payload P, + extensions *Extensions, + contextTable *ContextTable, +) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) { + raw, ct, bg, err := m.InvokeByName(hookName, payloadType, payload, extensions, contextTable) + if err != nil { + return nil, nil, nil, err + } + + typed := &TypedPipelineResult[P]{ + ContinueProcessing: raw.ContinueProcessing, + Violation: raw.Violation, + Errors: raw.Errors, + Metadata: raw.Metadata, + PayloadType: raw.PayloadType, + } + + // Deserialize modified payload if present + if len(raw.ModifiedPayload) > 0 { + var v P + if err := msgpack.Unmarshal(raw.ModifiedPayload, &v); err != nil { + return nil, ct, bg, fmt.Errorf("cpex: modified payload unmarshal failed: %w", err) + } + typed.ModifiedPayload = &v + } + + // Deserialize modified extensions if present + if len(raw.ModifiedExtensions) > 0 { + var ext Extensions + if err := msgpack.Unmarshal(raw.ModifiedExtensions, &ext); err != nil { + return nil, ct, bg, fmt.Errorf("cpex: modified extensions unmarshal failed: %w", err) + } + typed.ModifiedExtensions = &ext + } + + return typed, ct, bg, nil +} + +// Wait blocks until all background tasks complete. +// Returns structured errors from any tasks that failed (panicked, +// errored, or timed out), plus an error if the underlying FFI call +// failed (e.g., the manager was already shutdown). On FFI failure the +// returned slice is nil. +// +// Each PluginError carries the failing plugin's name, a message, an +// optional error code, structured details, and an optional protocol +// error code (JSON-RPC / HTTP) โ€” enough for an agent to classify +// failures without parsing strings. +// +// Holds the manager's read lock for the duration of the cgo call so +// the C handle can't be freed by a concurrent Shutdown. +func (bg *BackgroundTasks) Wait() ([]PluginError, error) { + if bg.handle == nil { + return nil, nil + } + if bg.mgr == nil { + return nil, fmt.Errorf("BackgroundTasks.Wait: %w", ErrCpexInvalidHandle) + } + + bg.mgr.mu.RLock() + defer bg.mgr.mu.RUnlock() + if bg.mgr.handle == nil { + // Rust still owns the BackgroundTasks box. The Rust-side + // `cpex_wait_background` consumes it even on the + // null-mgr path (P2 #11 fix), so we must not call into + // Rust without a live manager โ€” the box would leak. + // Best we can do is null our handle so the caller doesn't + // try again, and report the error. + bg.handle = nil + return nil, fmt.Errorf("BackgroundTasks.Wait: %w (manager shutdown; background tasks abandoned)", ErrCpexInvalidHandle) + } + + var errorsPtr *C.uint8_t + var errorsLen C.int + + rc := C.cpex_wait_background(bg.mgr.handle, bg.handle, &errorsPtr, &errorsLen) + bg.handle = nil // consumed by Rust regardless of rc (per P2 #11 fix) + + if rc != 0 { + // Output pointers are uninitialized on rc != 0 โ€” must NOT + // read them. C.GoBytes(nil, 0) is safe but reading garbage + // errorsPtr / errorsLen is UB. + return nil, errorFromRC(int(rc), "BackgroundTasks.Wait") + } + + errorsBytes := C.GoBytes(unsafe.Pointer(errorsPtr), errorsLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(errorsPtr)), errorsLen) + + var pluginErrors []PluginError + if err := msgpack.Unmarshal(errorsBytes, &pluginErrors); err != nil { + return nil, fmt.Errorf("BackgroundTasks.Wait: error decode failed: %w", err) + } + return pluginErrors, nil +} + +// Close releases the background task handles without waiting. +// Tasks continue running in the Rust tokio runtime. +func (bg *BackgroundTasks) Close() { + if bg.handle == nil { + return + } + C.cpex_free_background(bg.handle) + bg.handle = nil +} + +// Close releases the Rust-owned context table. +func (ct *ContextTable) Close() { + if ct.handle == nil { + return + } + C.cpex_free_context_table(ct.handle) + ct.handle = nil +} diff --git a/go/cpex/manager_test.go b/go/cpex/manager_test.go new file mode 100644 index 00000000..f5b31c5a --- /dev/null +++ b/go/cpex/manager_test.go @@ -0,0 +1,1175 @@ +// Location: ./go/cpex/manager_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tests for the CPEX Go SDK. +// +// These tests run against the real Rust runtime via cgo. The +// libcpex_ffi staticlib must be built before running: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "sync" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +func TestNewPluginManagerDefault(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if mgr.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + } + + if mgr.HasHooksFor("test_hook") { + t.Error("expected no hooks registered") + } +} + +func TestNewPluginManagerFromYAML(t *testing.T) { + yaml := ` +plugin_settings: + plugin_timeout: 30 +` + mgr, err := NewPluginManager(yaml) + if err != nil { + t.Fatalf("NewPluginManager failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if mgr.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + } +} + +func TestNewPluginManagerInvalidYAML(t *testing.T) { + _, err := NewPluginManager("not: [valid: yaml: {{}") + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestInvokeByNameNoPlugins(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Invoke with no registered plugins โ€” should return allowed + payload := map[string]any{ + "tool_name": "test_tool", + "user": "alice", + } + + ext := &Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "test_tool", + }, + } + + result, ctxTable, bg, err := mgr.InvokeByName("test_hook", PayloadGeneric, payload, ext, nil) + if err != nil { + t.Fatalf("InvokeByName failed: %v", err) + } + defer ctxTable.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed result with no plugins") + } + + if !result.ContinueProcessing { + t.Error("expected continue_processing=true") + } +} + +func TestInvokeByNameWithContextTableThreading(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + ext := &Extensions{} + + // First invocation โ€” nil context table + result1, ctxTable1, bg1, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, ext, nil) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg1.Close() + + if result1.IsDenied() { + t.Error("first invoke should be allowed") + } + + // Second invocation โ€” thread context table from first + result2, ctxTable2, bg2, err := mgr.InvokeByName("hook2", PayloadGeneric, payload, ext, ctxTable1) + if err != nil { + t.Fatalf("second invoke failed: %v", err) + } + bg2.Close() + + if result2.IsDenied() { + t.Error("second invoke should be allowed") + } + + ctxTable2.Close() +} + +func TestBackgroundTasksWait(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"test": true} + + result, ctxTable, bg, err := mgr.InvokeByName("test", PayloadGeneric, payload, nil, nil) + if err != nil { + t.Fatalf("invoke failed: %v", err) + } + defer ctxTable.Close() + + _ = result + + // Wait should return with no errors (no plugins to run) + errors, err := bg.Wait() + if err != nil { + t.Errorf("bg.Wait failed: %v", err) + } + if len(errors) > 0 { + t.Errorf("expected no background errors, got: %v", errors) + } +} + +// Concurrent goroutines invoking against a single manager must be safe +// (validates the P0 #1 aliased-&mut fix and the Pass 2 RWMutex). Run +// under -race to surface any data races on the handle. +func TestConcurrentInvokesAreSafe(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + const goroutines = 32 + const callsPerGoroutine = 16 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < callsPerGoroutine; j++ { + payload := map[string]any{"i": i, "j": j} + _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + if err != nil { + t.Errorf("invoke failed: %v", err) + return + } + if ct != nil { + ct.Close() + } + if bg != nil { + _, _ = bg.Wait() + } + } + }() + } + wg.Wait() +} + +// Calling Shutdown while goroutines are mid-invoke must not double-free +// or panic. After Shutdown, in-flight invokes should observe that the +// manager is shutdown and return an error gracefully. +func TestShutdownDuringInvokesIsSafe(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + // Spawn workers that invoke in a tight loop until they observe shutdown. + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + payload := map[string]any{"x": 1} + _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + if err != nil { + // Expected once Shutdown lands; just stop. + return + } + if ct != nil { + ct.Close() + } + if bg != nil { + _, _ = bg.Wait() + } + } + }() + } + + // Let them spin for a moment, then shutdown. + mgr.Shutdown() + close(stop) + wg.Wait() + + // Second Shutdown must be a no-op (P1 #4 fix โ€” finalizer cleared, + // double-call returns immediately). + mgr.Shutdown() +} + +// BackgroundTasks.Wait() called after the manager has been Shutdown +// must return ErrCpexInvalidHandle without crashing or reading +// uninitialized output pointers. Direct regression for the P1 #3 + +// P1 #5 fix path where bg holds a *PluginManager and checks handle +// nullness under the manager's RWMutex. +func TestBackgroundTasksWaitAfterShutdownReturnsTypedError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, _, bg, err := mgr.InvokeByName("noop", PayloadGeneric, map[string]any{}, nil, nil) + if err != nil { + t.Fatalf("InvokeByName failed: %v", err) + } + + // Tear the manager down BEFORE waiting on the background tasks. + mgr.Shutdown() + + // Wait must observe the shutdown handle and return a typed error + // โ€” not panic, not segfault on a stale C pointer, not silently + // return empty. + results, err := bg.Wait() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } + if results != nil { + t.Errorf("expected nil results on error path, got %v", results) + } +} + +// Invoking with an unknown payload_type discriminator must return an +// error wrapping ErrCpexParse โ€” the deserialize_payload registry +// rejects unknown values with RC_PARSE_ERROR, which the Go side +// classifies via errorFromRC. +func TestInvokeUnknownPayloadTypeReturnsParseError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + const unknownType uint8 = 99 // not in the FFI payload registry + _, _, _, err = mgr.InvokeByName("test_hook", unknownType, map[string]any{}, nil, nil) + if !errors.Is(err, ErrCpexParse) { + t.Errorf("expected ErrCpexParse for unknown payload_type, got %v", err) + } +} + +// IsInitialized reports manager lifecycle accurately: false until +// Initialize is called, true after, false again after Shutdown. +// Validates agent-native gap #4 โ€” introspection FFI. +func TestIsInitializedTracksLifecycle(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + if mgr.IsInitialized() { + t.Error("expected IsInitialized=false before Initialize") + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + if !mgr.IsInitialized() { + t.Error("expected IsInitialized=true after Initialize") + } + + mgr.Shutdown() + if mgr.IsInitialized() { + t.Error("expected IsInitialized=false after Shutdown") + } +} + +// PluginNames returns the names of plugins registered via YAML config. +// Validates agent-native gap #4 โ€” introspection FFI. +func TestPluginNamesEmptyByDefault(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + names, err := mgr.PluginNames() + if err != nil { + t.Fatalf("PluginNames failed: %v", err) + } + if len(names) != 0 { + t.Errorf("expected empty plugin names on a default manager, got %v", names) + } +} + +// BackgroundTasks.Wait returns []PluginError (structured) โ€” gap #3. +// On a no-plugin invoke the slice is empty but non-nil-shaped. +func TestBackgroundTasksWaitReturnsStructuredErrors(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, _, bg, err := mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + + errs, err := bg.Wait() + if err != nil { + t.Errorf("Wait failed: %v", err) + } + // errs is []PluginError โ€” typed at compile time. Empty for a + // no-plugin manager, but the structured type is what we wanted. + if len(errs) != 0 { + t.Errorf("expected no errors on no-plugin invoke, got %d: %v", len(errs), errs) + } +} + +// Operations on a shutdown manager must return an error wrapping +// ErrCpexInvalidHandle so callers can classify with errors.Is. +// Validates the P2 #18 typed-error mapping end-to-end. +func TestOperationsAfterShutdownReturnTypedError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + if err := mgr.Initialize(); !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("Initialize after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } + if err := mgr.LoadConfig("plugin_settings: {}"); !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("LoadConfig after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } + _, _, _, err = mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("InvokeByName after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } +} + +func TestPluginManagerDoubleShutdown(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + mgr.Shutdown() + // Second shutdown should not panic + mgr.Shutdown() +} + +func TestContextTableDoubleClose(t *testing.T) { + ct := &ContextTable{} + ct.Close() // should not panic + ct.Close() // should not panic +} + +func TestBackgroundTasksDoubleClose(t *testing.T) { + bg := &BackgroundTasks{} + bg.Close() // should not panic + bg.Close() // should not panic +} + +func TestPipelineResultIsDenied(t *testing.T) { + allowed := PipelineResult{ContinueProcessing: true} + if allowed.IsDenied() { + t.Error("expected not denied") + } + + denied := PipelineResult{ + ContinueProcessing: false, + Violation: &PluginViolation{ + Code: "test_denied", + Reason: "test reason", + }, + } + if !denied.IsDenied() { + t.Error("expected denied") + } +} + +func TestExtensionsSerialization(t *testing.T) { + ext := Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &SecurityExtension{ + Labels: []string{"PII"}, + Classification: "confidential", + Agent: &AgentIdentity{ + ClientID: "hr-agent", + WorkloadID: "spiffe://corp.com/hr-agent", + TrustDomain: "corp.com", + }, + }, + Http: &HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer tok", + "X-Request-ID": "req-123", + }, + }, + } + + // Verify it can be marshaled without error + _, err := msgpackMarshal(ext) + if err != nil { + t.Fatalf("extensions marshal failed: %v", err) + } +} + +// msgpackMarshal is a helper that imports msgpack for the test +func msgpackMarshal(v any) ([]byte, error) { + return msgpack.Marshal(v) +} + +// --------------------------------------------------------------------------- +// Typed Invoke Tests +// --------------------------------------------------------------------------- + +func TestInvokeTypedGenericPayload(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{ + "tool_name": "test_tool", + "user": "alice", + } + + result, ct, bg, err := Invoke[map[string]any]( + mgr, "test_hook", PayloadGeneric, payload, &Extensions{}, nil, + ) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + defer ct.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed result") + } + + if !result.ContinueProcessing { + t.Error("expected continue_processing=true") + } +} + +func TestInvokeTypedCMFPayload(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + msg := MessagePayload{ + Message: NewMessage("assistant", + NewTextPart("Looking up compensation data"), + NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), + } + + ext := &Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + } + + result, ct, bg, err := Invoke[MessagePayload]( + mgr, "cmf.tool_pre_invoke", PayloadCMFMessage, msg, ext, nil, + ) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + defer ct.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed with no plugins") + } +} + +func TestInvokeTypedContextThreading(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + + // First call โ€” nil context table + r1, ct1, bg1, err := Invoke[map[string]any]( + mgr, "hook1", PayloadGeneric, payload, &Extensions{}, nil, + ) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg1.Close() + + if r1.IsDenied() { + t.Error("first invoke should be allowed") + } + + // Second call โ€” thread context table + r2, ct2, bg2, err := Invoke[map[string]any]( + mgr, "hook2", PayloadGeneric, payload, &Extensions{}, ct1, + ) + if err != nil { + t.Fatalf("second invoke failed: %v", err) + } + bg2.Close() + + if r2.IsDenied() { + t.Error("second invoke should be allowed") + } + + ct2.Close() +} + +func TestTypedPipelineResultIsDenied(t *testing.T) { + allowed := TypedPipelineResult[map[string]any]{ContinueProcessing: true} + if allowed.IsDenied() { + t.Error("expected not denied") + } + + denied := TypedPipelineResult[map[string]any]{ + ContinueProcessing: false, + Violation: &PluginViolation{ + Code: "test", + Reason: "denied", + }, + } + if !denied.IsDenied() { + t.Error("expected denied") + } +} + +// --------------------------------------------------------------------------- +// CMF Content Part Tests +// --------------------------------------------------------------------------- + +func TestContentPartTextRoundTrip(t *testing.T) { + part := NewTextPart("hello world") + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "text" { + t.Errorf("expected content_type=text, got %s", decoded.ContentType) + } + if decoded.Text != "hello world" { + t.Errorf("expected text='hello world', got '%s'", decoded.Text) + } +} + +func TestContentPartThinkingRoundTrip(t *testing.T) { + part := NewThinkingPart("let me analyze...") + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "thinking" { + t.Errorf("expected content_type=thinking, got %s", decoded.ContentType) + } + if decoded.Text != "let me analyze..." { + t.Errorf("expected thinking text, got '%s'", decoded.Text) + } +} + +func TestContentPartToolCallRoundTrip(t *testing.T) { + part := NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_weather", + Arguments: map[string]any{"city": "London"}, + Namespace: "tools", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "tool_call" { + t.Errorf("expected content_type=tool_call, got %s", decoded.ContentType) + } + if decoded.ToolCallContent == nil { + t.Fatal("expected ToolCallContent to be set") + } + if decoded.ToolCallContent.Name != "get_weather" { + t.Errorf("expected name=get_weather, got %s", decoded.ToolCallContent.Name) + } + if decoded.ToolCallContent.ToolCallID != "tc_001" { + t.Errorf("expected tool_call_id=tc_001, got %s", decoded.ToolCallContent.ToolCallID) + } + if decoded.ToolCallContent.Namespace != "tools" { + t.Errorf("expected namespace=tools, got %s", decoded.ToolCallContent.Namespace) + } +} + +func TestContentPartToolResultRoundTrip(t *testing.T) { + part := NewToolResultPart(ToolResult{ + ToolCallID: "tc_001", + ToolName: "get_weather", + Content: map[string]any{"temp": 20, "unit": "C"}, + IsError: false, + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "tool_result" { + t.Errorf("expected content_type=tool_result, got %s", decoded.ContentType) + } + if decoded.ToolResultContent == nil { + t.Fatal("expected ToolResultContent to be set") + } + if decoded.ToolResultContent.ToolName != "get_weather" { + t.Errorf("expected tool_name=get_weather, got %s", decoded.ToolResultContent.ToolName) + } +} + +func TestContentPartResourceRoundTrip(t *testing.T) { + part := NewResourcePart(Resource{ + ResourceRequestID: "rr_001", + URI: "file:///data.txt", + ResourceType: "file", + Content: "Hello from file", + MimeType: "text/plain", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "resource" { + t.Errorf("expected content_type=resource, got %s", decoded.ContentType) + } + if decoded.ResourceContent == nil { + t.Fatal("expected ResourceContent to be set") + } + if decoded.ResourceContent.URI != "file:///data.txt" { + t.Errorf("expected uri=file:///data.txt, got %s", decoded.ResourceContent.URI) + } + if decoded.ResourceContent.Content != "Hello from file" { + t.Errorf("expected content='Hello from file', got '%s'", decoded.ResourceContent.Content) + } +} + +func TestContentPartImageRoundTrip(t *testing.T) { + part := NewImagePart(ImageSource{ + SourceType: "url", + Data: "https://example.com/photo.jpg", + MediaType: "image/jpeg", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "image" { + t.Errorf("expected content_type=image, got %s", decoded.ContentType) + } + if decoded.ImageContent == nil { + t.Fatal("expected ImageContent to be set") + } + if decoded.ImageContent.SourceType != "url" { + t.Errorf("expected type=url, got %s", decoded.ImageContent.SourceType) + } + if decoded.ImageContent.Data != "https://example.com/photo.jpg" { + t.Errorf("expected data URL, got %s", decoded.ImageContent.Data) + } +} + +func TestContentPartDocumentRoundTrip(t *testing.T) { + part := NewDocumentPart(DocumentSource{ + SourceType: "base64", + Data: "dGVzdA==", + MediaType: "application/pdf", + Title: "Quarterly Report", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "document" { + t.Errorf("expected content_type=document, got %s", decoded.ContentType) + } + if decoded.DocumentContent == nil { + t.Fatal("expected DocumentContent to be set") + } + if decoded.DocumentContent.Title != "Quarterly Report" { + t.Errorf("expected title='Quarterly Report', got '%s'", decoded.DocumentContent.Title) + } +} + +// Regression for P2 #13 โ€” `decodeVideoSource`/`decodeAudioSource` +// previously dropped DurationMs. With the generic decodeAs[T] helper +// driven by msgpack tags, fields can no longer be silently lost. +func TestContentPartVideoRoundTripWithDuration(t *testing.T) { + dur := uint64(15000) + part := NewVideoPart(VideoSource{ + SourceType: "url", + Data: "https://example.com/v.mp4", + MediaType: "video/mp4", + DurationMs: &dur, + }) + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.VideoContent == nil { + t.Fatal("VideoContent nil") + } + if decoded.VideoContent.DurationMs == nil || *decoded.VideoContent.DurationMs != 15000 { + t.Errorf("DurationMs lost: %v", decoded.VideoContent.DurationMs) + } +} + +// Regression for P2 #13 โ€” `decodeResource` previously dropped Blob +// and SizeBytes; `decodeResourceRef` dropped RangeStart and RangeEnd. +func TestContentPartResourceFieldsPreserved(t *testing.T) { + size := uint64(2048) + rstart := uint64(100) + rend := uint64(500) + + resource := NewResourcePart(Resource{ + ResourceRequestID: "rr_1", + URI: "file:///doc.bin", + ResourceType: "binary", + Blob: []byte{0xDE, 0xAD, 0xBE, 0xEF}, + SizeBytes: &size, + MimeType: "application/octet-stream", + }) + data, err := msgpack.Marshal(resource) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var d1 ContentPart + if err := msgpack.Unmarshal(data, &d1); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d1.ResourceContent == nil { + t.Fatal("ResourceContent nil") + } + if string(d1.ResourceContent.Blob) != "\xDE\xAD\xBE\xEF" { + t.Errorf("Blob lost: %v", d1.ResourceContent.Blob) + } + if d1.ResourceContent.SizeBytes == nil || *d1.ResourceContent.SizeBytes != 2048 { + t.Errorf("SizeBytes lost: %v", d1.ResourceContent.SizeBytes) + } + + ref := NewResourceRefPart(ResourceReference{ + ResourceRequestID: "rr_2", + URI: "file:///doc.bin", + ResourceType: "binary", + RangeStart: &rstart, + RangeEnd: &rend, + }) + data, err = msgpack.Marshal(ref) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var d2 ContentPart + if err := msgpack.Unmarshal(data, &d2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d2.ResourceRefContent == nil { + t.Fatal("ResourceRefContent nil") + } + if d2.ResourceRefContent.RangeStart == nil || *d2.ResourceRefContent.RangeStart != 100 { + t.Errorf("RangeStart lost: %v", d2.ResourceRefContent.RangeStart) + } + if d2.ResourceRefContent.RangeEnd == nil || *d2.ResourceRefContent.RangeEnd != 500 { + t.Errorf("RangeEnd lost: %v", d2.ResourceRefContent.RangeEnd) + } +} + +// Regression for P2 #13 โ€” `decodePromptResult` previously dropped +// the Messages field entirely (with a "TODO: nested decode" comment). +// The generic helper handles it correctly, including nested Messages +// with their own ContentPart custom decoder. +func TestContentPartPromptResultPreservesMessages(t *testing.T) { + pr := NewPromptResultPart(PromptResult{ + PromptRequestID: "pr_1", + PromptName: "summarize", + Messages: []Message{ + NewMessage("system", NewTextPart("You are concise.")), + NewMessage("user", NewTextPart("Summarize the report.")), + }, + }) + data, err := msgpack.Marshal(pr) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.PromptResultContent == nil { + t.Fatal("PromptResultContent nil") + } + if len(decoded.PromptResultContent.Messages) != 2 { + t.Fatalf("expected 2 nested messages, got %d", len(decoded.PromptResultContent.Messages)) + } + if decoded.PromptResultContent.Messages[0].Role != "system" { + t.Errorf("first nested role lost: %s", decoded.PromptResultContent.Messages[0].Role) + } + if len(decoded.PromptResultContent.Messages[1].Content) != 1 || + decoded.PromptResultContent.Messages[1].Content[0].Text != "Summarize the report." { + t.Errorf("nested content lost: %+v", decoded.PromptResultContent.Messages[1].Content) + } +} + +// Regression for P2 #17 โ€” unknown content_type variants previously +// decoded to an empty ContentPart and re-encoded as a text fallback, +// silently dropping the original payload. Now the raw map is captured +// on decode and emitted verbatim on encode, so a future variant from +// Rust passes through an older Go SDK without data loss. +func TestContentPartUnknownContentTypeRoundTrip(t *testing.T) { + // Simulate a future Rust variant by encoding a map directly. + original := map[string]any{ + "content_type": "future_variant", + "content": map[string]any{ + "new_field": "value", + "count": uint64(42), + }, + } + wire, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Decode through ContentPart's custom decoder, then re-encode. + var cp ContentPart + if err := msgpack.Unmarshal(wire, &cp); err != nil { + t.Fatalf("decode: %v", err) + } + if cp.ContentType != "future_variant" { + t.Errorf("ContentType lost: %s", cp.ContentType) + } + roundtripped, err := msgpack.Marshal(cp) + if err != nil { + t.Fatalf("re-encode: %v", err) + } + + // Decode the roundtripped wire as a plain map and verify the + // new_field is still there. + var back map[string]any + if err := msgpack.Unmarshal(roundtripped, &back); err != nil { + t.Fatalf("unmarshal back: %v", err) + } + contentMap, ok := back["content"].(map[string]any) + if !ok { + t.Fatalf("content field missing or wrong type after roundtrip: %#v", back) + } + if contentMap["new_field"] != "value" { + t.Errorf("new_field lost across roundtrip: %#v", contentMap) + } +} + +// Regression for P2 #14, #15, #16 โ€” Extension fields that Rust +// serializes but Go was silently dropping. Round-trip a populated +// Extensions through msgpack and verify each field survives. +func TestExtensionsAddedFieldsRoundTrip(t *testing.T) { + turn := uint32(7) + ext := &Extensions{ + Agent: &AgentExtension{ + SessionID: "sess_1", + ConversationID: "conv_1", + Turn: &turn, + AgentID: "agent_1", + Conversation: &ConversationContext{ + History: []any{"prior turn"}, + Summary: "user asked for compensation lookup", + Topics: []string{"hr", "compensation"}, + }, + }, + MCP: &MCPExtension{ + Tool: &ToolMetadata{ + Name: "get_compensation", + OutputSchema: map[string]any{"type": "object"}, + Annotations: map[string]any{"audit_required": true}, + }, + }, + Completion: &CompletionExtension{ + Model: "claude-sonnet-4-6", + RawFormat: "anthropic", + CreatedAt: "2026-05-04T10:00:00Z", + }, + } + + data, err := msgpack.Marshal(ext) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back Extensions + if err := msgpack.Unmarshal(data, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if back.Agent == nil || back.Agent.Conversation == nil { + t.Fatal("Agent.Conversation lost") + } + if back.Agent.Conversation.Summary != "user asked for compensation lookup" { + t.Errorf("Conversation.Summary lost: %q", back.Agent.Conversation.Summary) + } + if len(back.Agent.Conversation.Topics) != 2 { + t.Errorf("Conversation.Topics lost: %v", back.Agent.Conversation.Topics) + } + if back.Agent.Turn == nil || *back.Agent.Turn != 7 { + t.Errorf("Turn lost or wrong type: %v", back.Agent.Turn) + } + + if back.MCP == nil || back.MCP.Tool == nil { + t.Fatal("MCP.Tool lost") + } + if back.MCP.Tool.OutputSchema == nil { + t.Error("Tool.OutputSchema lost") + } + if back.MCP.Tool.Annotations == nil { + t.Error("Tool.Annotations lost") + } + + if back.Completion == nil { + t.Fatal("Completion lost") + } + if back.Completion.RawFormat != "anthropic" { + t.Errorf("Completion.RawFormat lost: %q", back.Completion.RawFormat) + } + if back.Completion.CreatedAt != "2026-05-04T10:00:00Z" { + t.Errorf("Completion.CreatedAt lost: %q", back.Completion.CreatedAt) + } +} + +func TestMessagePayloadSerialization(t *testing.T) { + msg := MessagePayload{ + Message: NewMessage("assistant", + NewTextPart("I'll look that up for you."), + NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), + } + + data, err := msgpack.Marshal(msg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if len(data) == 0 { + t.Fatal("expected non-empty msgpack bytes") + } + + // Verify it round-trips as a generic map (to check wire format) + var raw map[string]any + if err := msgpack.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + message, ok := raw["message"].(map[string]any) + if !ok { + t.Fatal("expected 'message' key in payload") + } + + if message["schema_version"] != "2.0" { + t.Errorf("expected schema_version=2.0, got %v", message["schema_version"]) + } + + if message["role"] != "assistant" { + t.Errorf("expected role=assistant, got %v", message["role"]) + } + + content, ok := message["content"].([]any) + if !ok { + t.Fatal("expected content to be a list") + } + + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + + // First part should be text + part0, ok := content[0].(map[string]any) + if !ok { + t.Fatal("expected content[0] to be a map") + } + if part0["content_type"] != "text" { + t.Errorf("expected content_type=text, got %v", part0["content_type"]) + } + + // Second part should be tool_call + part1, ok := content[1].(map[string]any) + if !ok { + t.Fatal("expected content[1] to be a map") + } + if part1["content_type"] != "tool_call" { + t.Errorf("expected content_type=tool_call, got %v", part1["content_type"]) + } +} + +func TestLoadConfigOnDefaultManager(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + // LoadConfig with valid YAML (no plugins, just settings) + err = mgr.LoadConfig(` +plugin_settings: + plugin_timeout: 30 +`) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } +} + +func TestLoadConfigInvalidYAML(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + err = mgr.LoadConfig("not: [valid: yaml: {{}") + if err == nil { + t.Error("expected error for invalid YAML") + } +} diff --git a/go/cpex/types.go b/go/cpex/types.go new file mode 100644 index 00000000..04cd0adc --- /dev/null +++ b/go/cpex/types.go @@ -0,0 +1,318 @@ +// Location: ./go/cpex/types.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Go types โ€” extensions, pipeline results, and payload constants. +// +// All types use msgpack struct tags matching the Rust field names +// for zero-copy serialization across the FFI boundary. Extension +// types mirror crates/cpex-core/src/extensions/. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// Extensions carries capability-gated data alongside the payload. +// Serialized to/from MessagePack when crossing the FFI boundary. +type Extensions struct { + Meta *MetaExtension `msgpack:"meta,omitempty"` + Security *SecurityExtension `msgpack:"security,omitempty"` + Http *HttpExtension `msgpack:"http,omitempty"` + Delegation *DelegationExtension `msgpack:"delegation,omitempty"` + Agent *AgentExtension `msgpack:"agent,omitempty"` + Request *RequestExtension `msgpack:"request,omitempty"` + MCP *MCPExtension `msgpack:"mcp,omitempty"` + Completion *CompletionExtension `msgpack:"completion,omitempty"` + Provenance *ProvenanceExtension `msgpack:"provenance,omitempty"` + LLM *LLMExtension `msgpack:"llm,omitempty"` + Framework *FrameworkExtension `msgpack:"framework,omitempty"` + Custom map[string]any `msgpack:"custom,omitempty"` +} + +// MetaExtension carries entity identification for route resolution. +type MetaExtension struct { + EntityType string `msgpack:"entity_type,omitempty"` + EntityName string `msgpack:"entity_name,omitempty"` + Tags []string `msgpack:"tags,omitempty"` + Scope string `msgpack:"scope,omitempty"` + Properties map[string]string `msgpack:"properties,omitempty"` +} + +// SecurityExtension carries identity, labels, and data policies. +type SecurityExtension struct { + Labels []string `msgpack:"labels,omitempty"` + Classification string `msgpack:"classification,omitempty"` + Subject *SubjectExtension `msgpack:"subject,omitempty"` + Agent *AgentIdentity `msgpack:"agent,omitempty"` + AuthMethod string `msgpack:"auth_method,omitempty"` + Objects map[string]ObjectSecurityProfile `msgpack:"objects,omitempty"` + Data map[string]DataPolicy `msgpack:"data,omitempty"` +} + +// SubjectExtension represents the authenticated caller. +type SubjectExtension struct { + ID string `msgpack:"id,omitempty"` + SubjectType string `msgpack:"subject_type,omitempty"` + Roles []string `msgpack:"roles,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + Teams []string `msgpack:"teams,omitempty"` + Claims map[string]string `msgpack:"claims,omitempty"` +} + +// AgentIdentity represents this agent's own workload identity. +type AgentIdentity struct { + ClientID string `msgpack:"client_id,omitempty"` + WorkloadID string `msgpack:"workload_id,omitempty"` + TrustDomain string `msgpack:"trust_domain,omitempty"` +} + +// ObjectSecurityProfile is a security profile for a managed object. +type ObjectSecurityProfile struct { + ManagedBy string `msgpack:"managed_by,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + TrustDomain string `msgpack:"trust_domain,omitempty"` + DataScope []string `msgpack:"data_scope,omitempty"` +} + +// DataPolicy defines data handling policies. +type DataPolicy struct { + ApplyLabels []string `msgpack:"apply_labels,omitempty"` + AllowedActions []string `msgpack:"allowed_actions,omitempty"` + DeniedActions []string `msgpack:"denied_actions,omitempty"` + Retention *RetentionPolicy `msgpack:"retention,omitempty"` +} + +// RetentionPolicy defines data retention rules. +type RetentionPolicy struct { + MaxAgeSeconds *uint64 `msgpack:"max_age_seconds,omitempty"` + Policy string `msgpack:"policy,omitempty"` + DeleteAfter string `msgpack:"delete_after,omitempty"` +} + +// HttpExtension carries HTTP request and response headers. +type HttpExtension struct { + RequestHeaders map[string]string `msgpack:"request_headers,omitempty"` + ResponseHeaders map[string]string `msgpack:"response_headers,omitempty"` +} + +// DelegationExtension carries the token delegation chain. +type DelegationExtension struct { + Chain []DelegationHop `msgpack:"chain,omitempty"` + Depth int `msgpack:"depth,omitempty"` + OriginSubjectID string `msgpack:"origin_subject_id,omitempty"` + ActorSubjectID string `msgpack:"actor_subject_id,omitempty"` + Delegated bool `msgpack:"delegated,omitempty"` + AgeSeconds float64 `msgpack:"age_seconds,omitempty"` +} + +// DelegationHop is a single step in the delegation chain. +type DelegationHop struct { + SubjectID string `msgpack:"subject_id,omitempty"` + SubjectType string `msgpack:"subject_type,omitempty"` + Audience string `msgpack:"audience,omitempty"` + ScopesGranted []string `msgpack:"scopes_granted,omitempty"` + Timestamp string `msgpack:"timestamp,omitempty"` + TTLSeconds *uint64 `msgpack:"ttl_seconds,omitempty"` + Strategy string `msgpack:"strategy,omitempty"` + FromCache bool `msgpack:"from_cache,omitempty"` +} + +// AgentExtension carries agent execution context. +type AgentExtension struct { + Input string `msgpack:"input,omitempty"` + SessionID string `msgpack:"session_id,omitempty"` + ConversationID string `msgpack:"conversation_id,omitempty"` + // Turn is *uint32 to match Rust's Option. Previously *int (64-bit + // in Go) โ€” values >2^32 would overflow the Rust side silently. + Turn *uint32 `msgpack:"turn,omitempty"` + AgentID string `msgpack:"agent_id,omitempty"` + ParentAgentID string `msgpack:"parent_agent_id,omitempty"` + // Conversation mirrors Rust's `conversation: Option`. + // Previously absent โ€” Rust serialized this field but Go silently dropped + // it (P2 #16). + Conversation *ConversationContext `msgpack:"conversation,omitempty"` +} + +// ConversationContext is per-conversation summary state, shared across +// turns. Mirrors `cpex_core::extensions::agent::ConversationContext`. +type ConversationContext struct { + // Recent conversation history, lightweight summaries (free-form + // JSON-style values to match Rust's Vec). + History []any `msgpack:"history,omitempty"` + // LLM-generated conversation summary. + Summary string `msgpack:"summary,omitempty"` + // Detected topics for routing / classification. + Topics []string `msgpack:"topics,omitempty"` +} + +// RequestExtension carries execution environment and tracing. +type RequestExtension struct { + Environment string `msgpack:"environment,omitempty"` + RequestID string `msgpack:"request_id,omitempty"` + Timestamp string `msgpack:"timestamp,omitempty"` + TraceID string `msgpack:"trace_id,omitempty"` + SpanID string `msgpack:"span_id,omitempty"` +} + +// MCPExtension carries MCP entity metadata. +type MCPExtension struct { + Tool *ToolMetadata `msgpack:"tool,omitempty"` + Resource *ResourceMetadata `msgpack:"resource,omitempty"` + Prompt *PromptMetadata `msgpack:"prompt,omitempty"` +} + +// ToolMetadata is MCP tool metadata. +type ToolMetadata struct { + Name string `msgpack:"name"` + Title string `msgpack:"title,omitempty"` + Description string `msgpack:"description,omitempty"` + InputSchema map[string]any `msgpack:"input_schema,omitempty"` + // OutputSchema and Annotations were missing โ€” Rust serialized them, + // Go silently dropped them (P2 #15). + OutputSchema map[string]any `msgpack:"output_schema,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` + Namespace string `msgpack:"namespace,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` +} + +// ResourceMetadata is MCP resource metadata. +type ResourceMetadata struct { + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + Description string `msgpack:"description,omitempty"` + MimeType string `msgpack:"mime_type,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// PromptMetadata is MCP prompt metadata. +type PromptMetadata struct { + Name string `msgpack:"name"` + Description string `msgpack:"description,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// CompletionExtension carries LLM completion information. +type CompletionExtension struct { + StopReason string `msgpack:"stop_reason,omitempty"` + Tokens *TokenUsage `msgpack:"tokens,omitempty"` + Model string `msgpack:"model,omitempty"` + // RawFormat and CreatedAt were missing โ€” Rust serialized them, + // Go silently dropped them (P2 #14). + RawFormat string `msgpack:"raw_format,omitempty"` + CreatedAt string `msgpack:"created_at,omitempty"` + LatencyMs *uint64 `msgpack:"latency_ms,omitempty"` +} + +// TokenUsage is token usage statistics. +type TokenUsage struct { + InputTokens int `msgpack:"input_tokens,omitempty"` + OutputTokens int `msgpack:"output_tokens,omitempty"` + TotalTokens int `msgpack:"total_tokens,omitempty"` +} + +// ProvenanceExtension carries origin and message threading. +type ProvenanceExtension struct { + Source string `msgpack:"source,omitempty"` + MessageID string `msgpack:"message_id,omitempty"` + ParentID string `msgpack:"parent_id,omitempty"` +} + +// LLMExtension carries model identity and capabilities. +type LLMExtension struct { + ModelID string `msgpack:"model_id,omitempty"` + Provider string `msgpack:"provider,omitempty"` + Capabilities []string `msgpack:"capabilities,omitempty"` +} + +// FrameworkExtension carries agentic framework context. +type FrameworkExtension struct { + Framework string `msgpack:"framework,omitempty"` + FrameworkVersion string `msgpack:"framework_version,omitempty"` + NodeID string `msgpack:"node_id,omitempty"` + GraphID string `msgpack:"graph_id,omitempty"` + Metadata map[string]any `msgpack:"metadata,omitempty"` +} + +// PluginViolation is a structured policy denial. +type PluginViolation struct { + Code string `msgpack:"code"` + Reason string `msgpack:"reason"` + Description string `msgpack:"description,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + PluginName string `msgpack:"plugin_name,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} + +// PluginError is a plugin execution error. +type PluginError struct { + PluginName string `msgpack:"plugin_name"` + Message string `msgpack:"message"` + Code string `msgpack:"code,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} + +// PipelineResult is the aggregate result from a hook invocation. +type PipelineResult struct { + ContinueProcessing bool `msgpack:"continue_processing"` + Violation *PluginViolation `msgpack:"violation,omitempty"` + // Errors from plugins that ran with on_error: ignore or + // on_error: disable. Empty when no plugin errored on a non-halt + // path. Fire-and-forget errors live on BackgroundTasks.Wait() + // instead. + Errors []PluginError `msgpack:"errors,omitempty"` + Metadata map[string]any `msgpack:"metadata,omitempty"` + // Payload type ID โ€” tells the caller how to deserialize ModifiedPayload. + PayloadType uint8 `msgpack:"payload_type"` + // Modified payload as raw MessagePack bytes. + ModifiedPayload []byte `msgpack:"modified_payload,omitempty"` + // Modified extensions as raw MessagePack bytes. + ModifiedExtensions []byte `msgpack:"modified_extensions,omitempty"` +} + +// TypedPipelineResult is a PipelineResult with the modified payload +// and extensions deserialized into concrete Go types. +type TypedPipelineResult[P any] struct { + ContinueProcessing bool + Violation *PluginViolation + Errors []PluginError + Metadata map[string]any + PayloadType uint8 + ModifiedPayload *P + ModifiedExtensions *Extensions +} + +// IsDenied returns true if the pipeline was halted by a plugin. +func (r *TypedPipelineResult[P]) IsDenied() bool { + return !r.ContinueProcessing +} + +// DeserializePayload deserializes the modified payload into a typed struct. +func DeserializePayload[T any](result *PipelineResult) (*T, error) { + if len(result.ModifiedPayload) == 0 { + return nil, nil + } + var v T + if err := msgpack.Unmarshal(result.ModifiedPayload, &v); err != nil { + return nil, err + } + return &v, nil +} + +// DeserializeExtensions deserializes the modified extensions. +func (r *PipelineResult) DeserializeExtensions() (*Extensions, error) { + if len(r.ModifiedExtensions) == 0 { + return nil, nil + } + var ext Extensions + if err := msgpack.Unmarshal(r.ModifiedExtensions, &ext); err != nil { + return nil, err + } + return &ext, nil +} + +// IsDenied returns true if the pipeline was halted by a plugin. +func (r *PipelineResult) IsDenied() bool { + return !r.ContinueProcessing +}